@@ -20,6 +20,8 @@ import {
2020 ShieldAlert ,
2121 ShieldCheck ,
2222 Sparkles ,
23+ TrendingDown ,
24+ TrendingUp ,
2325 UserRound ,
2426 XCircle ,
2527 ZoomIn ,
@@ -105,6 +107,35 @@ const GRAPH_HEIGHT = 660;
105107const RANGE_OPTIONS = [ '7d' , '30d' , '90d' , 'all' ] as const ;
106108const TYPE_OPTIONS = [ 'all' , 'voice' , 'text' ] as const ;
107109
110+ const STATS_SNAPSHOT_KEY = 'clerktree_customer_graph_stats_snapshot_v1' ;
111+
112+ interface StatsSnapshot {
113+ totalCustomers : number ;
114+ totalEdges : number ;
115+ totalClusters : number ;
116+ highRiskClusters : number ;
117+ opportunityClusters : number ;
118+ capturedAt : string ;
119+ }
120+
121+ function loadStatsSnapshot ( ) : StatsSnapshot | null {
122+ try {
123+ const raw = window . localStorage . getItem ( STATS_SNAPSHOT_KEY ) ;
124+ if ( ! raw ) return null ;
125+ return JSON . parse ( raw ) as StatsSnapshot ;
126+ } catch {
127+ return null ;
128+ }
129+ }
130+
131+ function saveStatsSnapshot ( stats : StatsSnapshot ) : void {
132+ try {
133+ window . localStorage . setItem ( STATS_SNAPSHOT_KEY , JSON . stringify ( stats ) ) ;
134+ } catch {
135+ // Ignore storage failures.
136+ }
137+ }
138+
108139function formatPercent ( value : number ) : string {
109140 return `${ Math . round ( value * 100 ) } %` ;
110141}
@@ -182,6 +213,7 @@ export default function CustomerGraphView({ isDark = true }: CustomerGraphViewPr
182213 const [ selectedClusterId , setSelectedClusterId ] = useState < string | null > ( null ) ;
183214 const [ hoveredProfileId , setHoveredProfileId ] = useState < string | null > ( null ) ;
184215
216+ const [ previousStats , setPreviousStats ] = useState < StatsSnapshot | null > ( null ) ;
185217 const [ renderedNodes , setRenderedNodes ] = useState < VisualNode [ ] > ( [ ] ) ;
186218 const [ renderedLinks , setRenderedLinks ] = useState < VisualLink [ ] > ( [ ] ) ;
187219
@@ -284,6 +316,24 @@ export default function CustomerGraphView({ isDark = true }: CustomerGraphViewPr
284316 setGraphModelFromView ( model ) ;
285317 } , [ model , setGraphModelFromView ] ) ;
286318
319+ useEffect ( ( ) => {
320+ const prev = loadStatsSnapshot ( ) ;
321+ if ( prev ) setPreviousStats ( prev ) ;
322+ } , [ ] ) ;
323+
324+ useEffect ( ( ) => {
325+ if ( ! model ) return ;
326+ const snapshot : StatsSnapshot = {
327+ totalCustomers : model . stats . totalCustomers ,
328+ totalEdges : model . stats . totalEdges ,
329+ totalClusters : model . stats . totalClusters ,
330+ highRiskClusters : model . stats . highRiskClusters ,
331+ opportunityClusters : model . stats . opportunityClusters ,
332+ capturedAt : model . generatedAt ,
333+ } ;
334+ saveStatsSnapshot ( snapshot ) ;
335+ } , [ model ] ) ;
336+
287337 useEffect ( ( ) => {
288338 if ( selectedPlaybookId || playbooks . length === 0 ) return ;
289339 setSelectedPlaybookId ( playbooks [ 0 ] . id ) ;
@@ -935,34 +985,39 @@ export default function CustomerGraphView({ isDark = true }: CustomerGraphViewPr
935985 < MetricCard
936986 title = { t ( 'customerGraph.kpi.customers' ) }
937987 value = { model ?. stats . totalCustomers ?? 0 }
988+ previousValue = { previousStats ?. totalCustomers }
938989 icon = { < UserRound className = "w-4 h-4" /> }
939990 color = "cyan"
940991 isDark = { isDark }
941992 />
942993 < MetricCard
943994 title = { t ( 'customerGraph.kpi.clusters' ) }
944995 value = { model ?. stats . totalClusters ?? 0 }
996+ previousValue = { previousStats ?. totalClusters }
945997 icon = { < Network className = "w-4 h-4" /> }
946998 color = "blue"
947999 isDark = { isDark }
9481000 />
9491001 < MetricCard
9501002 title = { t ( 'customerGraph.kpi.edges' ) }
9511003 value = { model ?. stats . totalEdges ?? 0 }
1004+ previousValue = { previousStats ?. totalEdges }
9521005 icon = { < Sparkles className = "w-4 h-4" /> }
9531006 color = "purple"
9541007 isDark = { isDark }
9551008 />
9561009 < MetricCard
9571010 title = { t ( 'customerGraph.kpi.risk' ) }
9581011 value = { model ?. stats . highRiskClusters ?? 0 }
1012+ previousValue = { previousStats ?. highRiskClusters }
9591013 icon = { < ShieldAlert className = "w-4 h-4" /> }
9601014 color = "orange"
9611015 isDark = { isDark }
9621016 />
9631017 < MetricCard
9641018 title = { t ( 'customerGraph.kpi.opportunity' ) }
9651019 value = { model ?. stats . opportunityClusters ?? 0 }
1020+ previousValue = { previousStats ?. opportunityClusters }
9661021 icon = { < Brain className = "w-4 h-4" /> }
9671022 color = "emerald"
9681023 isDark = { isDark }
@@ -1650,6 +1705,11 @@ export default function CustomerGraphView({ isDark = true }: CustomerGraphViewPr
16501705 < p className = { cn ( 'text-sm font-semibold' , isDark ? 'text-white' : 'text-black' ) } >
16511706 { getLabel ( selectedProfile ) }
16521707 </ p >
1708+ { selectedProfile . contact . email && (
1709+ < p className = { cn ( 'text-[11px] mt-1' , isDark ? 'text-white/50' : 'text-black/50' ) } >
1710+ { selectedProfile . contact . email }
1711+ </ p >
1712+ ) }
16531713 < div className = { cn ( 'grid grid-cols-2 gap-2 mt-2 text-[11px]' , isDark ? 'text-white/60' : 'text-black/60' ) } >
16541714 < div >
16551715 < p > { t ( 'customerGraph.details.interactions' ) } </ p >
@@ -1659,6 +1719,41 @@ export default function CustomerGraphView({ isDark = true }: CustomerGraphViewPr
16591719 < p > { t ( 'customerGraph.details.lastSeen' ) } </ p >
16601720 < p className = { cn ( 'font-semibold text-sm mt-0.5' , isDark ? 'text-white' : 'text-black' ) } > { formatDate ( selectedProfile . lastSeen ) } </ p >
16611721 </ div >
1722+ < div >
1723+ < p > Risk</ p >
1724+ < div className = "flex items-center gap-1.5 mt-0.5" >
1725+ < div className = { cn ( 'h-1.5 rounded-full flex-1 overflow-hidden' , isDark ? 'bg-white/10' : 'bg-black/10' ) } >
1726+ < div
1727+ className = { cn (
1728+ 'h-full rounded-full' ,
1729+ selectedProfile . riskScore >= 0.65
1730+ ? 'bg-rose-400'
1731+ : selectedProfile . riskScore >= 0.4
1732+ ? 'bg-amber-400'
1733+ : 'bg-emerald-400' ,
1734+ ) }
1735+ style = { { width : `${ Math . max ( 4 , Math . round ( selectedProfile . riskScore * 100 ) ) } %` } }
1736+ />
1737+ </ div >
1738+ < span className = { cn ( 'font-semibold text-xs' , isDark ? 'text-white' : 'text-black' ) } >
1739+ { Math . round ( selectedProfile . riskScore * 100 ) } %
1740+ </ span >
1741+ </ div >
1742+ </ div >
1743+ < div >
1744+ < p > Opportunity</ p >
1745+ < div className = "flex items-center gap-1.5 mt-0.5" >
1746+ < div className = { cn ( 'h-1.5 rounded-full flex-1 overflow-hidden' , isDark ? 'bg-white/10' : 'bg-black/10' ) } >
1747+ < div
1748+ className = "h-full rounded-full bg-cyan-400"
1749+ style = { { width : `${ Math . max ( 4 , Math . round ( selectedProfile . opportunityScore * 100 ) ) } %` } }
1750+ />
1751+ </ div >
1752+ < span className = { cn ( 'font-semibold text-xs' , isDark ? 'text-white' : 'text-black' ) } >
1753+ { Math . round ( selectedProfile . opportunityScore * 100 ) } %
1754+ </ span >
1755+ </ div >
1756+ </ div >
16621757 </ div >
16631758 </ div >
16641759
@@ -2345,16 +2440,28 @@ export default function CustomerGraphView({ isDark = true }: CustomerGraphViewPr
23452440function MetricCard ( {
23462441 title,
23472442 value,
2443+ previousValue,
23482444 icon,
23492445 color,
23502446 isDark,
23512447} : {
23522448 title : string ;
23532449 value : number ;
2450+ previousValue ?: number ;
23542451 icon : ReactNode ;
23552452 color : 'cyan' | 'blue' | 'purple' | 'orange' | 'emerald' ;
23562453 isDark : boolean ;
23572454} ) {
2455+ const delta = previousValue !== undefined && previousValue !== 0
2456+ ? value - previousValue
2457+ : null ;
2458+ const deltaPercent = delta !== null && previousValue
2459+ ? Math . round ( ( delta / previousValue ) * 100 )
2460+ : null ;
2461+ const isUp = delta !== null && delta > 0 ;
2462+ const isDown = delta !== null && delta < 0 ;
2463+ const isNeutral = delta === null || delta === 0 ;
2464+
23582465 return (
23592466 < div className = { cn (
23602467 'relative overflow-hidden rounded-xl border p-4' ,
@@ -2380,9 +2487,34 @@ function MetricCard({
23802487 </ span >
23812488 </ div >
23822489
2383- < p className = { cn ( 'text-3xl font-semibold mt-2' , isDark ? 'text-white' : 'text-black' ) } >
2384- { value }
2385- </ p >
2490+ < div className = "flex items-end gap-2 mt-2" >
2491+ < p className = { cn ( 'text-3xl font-semibold' , isDark ? 'text-white' : 'text-black' ) } >
2492+ { value }
2493+ </ p >
2494+ { ! isNeutral && deltaPercent !== null && (
2495+ < span className = { cn (
2496+ 'inline-flex items-center gap-0.5 text-[11px] font-semibold px-1.5 py-0.5 rounded-md mb-1' ,
2497+ isUp
2498+ ? ( color === 'orange'
2499+ ? ( isDark ? 'bg-rose-500/15 text-rose-300' : 'bg-rose-50 text-rose-600' )
2500+ : ( isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-50 text-emerald-600' ) )
2501+ : ( color === 'orange'
2502+ ? ( isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-50 text-emerald-600' )
2503+ : ( isDark ? 'bg-rose-500/15 text-rose-300' : 'bg-rose-50 text-rose-600' ) ) ,
2504+ ) } >
2505+ { isUp ? < TrendingUp className = "w-3 h-3" /> : < TrendingDown className = "w-3 h-3" /> }
2506+ { isUp ? '+' : '' } { deltaPercent } %
2507+ </ span >
2508+ ) }
2509+ { isNeutral && previousValue !== undefined && (
2510+ < span className = { cn (
2511+ 'inline-flex items-center gap-0.5 text-[11px] font-medium px-1.5 py-0.5 rounded-md mb-1' ,
2512+ isDark ? 'bg-white/5 text-white/45' : 'bg-black/5 text-black/45' ,
2513+ ) } >
2514+ -
2515+ </ span >
2516+ ) }
2517+ </ div >
23862518 </ div >
23872519 </ div >
23882520 ) ;
0 commit comments