From 9f0bfd44f950232a23306e7591e49e0990352355 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Mon, 27 Apr 2026 16:00:34 -0400 Subject: [PATCH] feat(escalations): mount time-to-first-action stat-card on /escalations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/api/flowpilotAnalytics.ts | 16 ++- .../flowpilot/EscalationMetricCard.tsx | 130 ++++++++++++++++++ frontend/src/components/flowpilot/index.ts | 1 + frontend/src/pages/EscalationQueuePage.tsx | 4 +- frontend/src/types/flowpilot-analytics.ts | 13 ++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/flowpilot/EscalationMetricCard.tsx diff --git a/frontend/src/api/flowpilotAnalytics.ts b/frontend/src/api/flowpilotAnalytics.ts index 0f4ccca4..27552bee 100644 --- a/frontend/src/api/flowpilotAnalytics.ts +++ b/frontend/src/api/flowpilotAnalytics.ts @@ -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 { @@ -36,6 +43,13 @@ export const flowpilotAnalyticsApi = { }) return response.data }, + + async getEscalationMetrics(period: string = '30d'): Promise { + const response = await apiClient.get('/analytics/flowpilot/escalations', { + params: { period }, + }) + return response.data + }, } export default flowpilotAnalyticsApi diff --git a/frontend/src/components/flowpilot/EscalationMetricCard.tsx b/frontend/src/components/flowpilot/EscalationMetricCard.tsx new file mode 100644 index 00000000..78b97d34 --- /dev/null +++ b/frontend/src/components/flowpilot/EscalationMetricCard.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+
+
+ ) + } + + if (error) { + return ( +
+ + {error} +
+ ) + } + + if (!metrics || metrics.n_handoffs_claimed === 0) { + return ( +
+

+ Time to first action ({period}) +

+

+ No claimed escalations yet. Once your team starts using Pick Up, + we'll measure how fast they get into resolution. +

+
+ ) + } + + 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 ( +
+
+ + Time to first action — last {period} +
+ +
+
+ + {avgLabel} + + avg +
+
+ {medianLabel} median +
+
+ + {metrics.n_handoffs_with_action} + + /{metrics.n_handoffs_claimed} claimed escalations + + ({conversionRate}% reached first action) + +
+
+ +

+ + + 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. + +

+
+ ) +} diff --git a/frontend/src/components/flowpilot/index.ts b/frontend/src/components/flowpilot/index.ts index 3fe5cc4e..0cdb9db0 100644 --- a/frontend/src/components/flowpilot/index.ts +++ b/frontend/src/components/flowpilot/index.ts @@ -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' diff --git a/frontend/src/pages/EscalationQueuePage.tsx b/frontend/src/pages/EscalationQueuePage.tsx index cddbff18..5ae5a20e 100644 --- a/frontend/src/pages/EscalationQueuePage.tsx +++ b/frontend/src/pages/EscalationQueuePage.tsx @@ -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(null) @@ -21,6 +21,8 @@ export default function EscalationQueuePage() {
+ + ) diff --git a/frontend/src/types/flowpilot-analytics.ts b/frontend/src/types/flowpilot-analytics.ts index f1446f43..b5767060 100644 --- a/frontend/src/types/flowpilot-analytics.ts +++ b/frontend/src/types/flowpilot-analytics.ts @@ -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 +}