11import { useEffect , useMemo , useState } from 'react'
22import { AreaChart , Area , CartesianGrid , XAxis , YAxis , Tooltip , ResponsiveContainer } from 'recharts'
3+ import { supabase } from '../lib/supabase'
34import { CHART_COLORS , CHART_TYPOGRAPHY } from '../lib/brandColors'
45import ChartDialog from './ChartDialog'
56import DrilldownPanel from './DrilldownPanel'
@@ -224,12 +225,15 @@ const aggregateDimensionRows = (runs, dimension) => {
224225 . sort ( ( a , b ) => ( b . run_count - a . run_count ) || ( b . input_tokens + b . output_tokens ) - ( a . input_tokens + a . output_tokens ) )
225226}
226227
227- function SkillsPanel ( { skillRuns = [ ] , skillDailyStats = [ ] , dateRange, customRange, isDarkMode } ) {
228+ function SkillsPanel ( { skillRuns = [ ] , skillDailyStats = [ ] , dateRange, customRange, rangeBoundaries , isDarkMode } ) {
228229 const [ skillSort , setSkillSort ] = useState ( 'runs' )
229230 const [ trendTime , setTrendTime ] = useState ( 'day' )
230231 const [ tableSortCol , setTableSortCol ] = useState ( 'triggered_at' )
231232 const [ tableSortDir , setTableSortDir ] = useState ( 'desc' )
232233 const [ activeSkillName , setActiveSkillName ] = useState ( null )
234+ const [ previousAvgTokensPerRun , setPreviousAvgTokensPerRun ] = useState ( null )
235+ const [ previousPeriodRunCount , setPreviousPeriodRunCount ] = useState ( 0 )
236+ const [ isPreviousAvgLoading , setIsPreviousAvgLoading ] = useState ( false )
233237
234238 const handleTableSort = ( key ) => {
235239 if ( tableSortCol === key ) {
@@ -363,22 +367,106 @@ function SkillsPanel({ skillRuns = [], skillDailyStats = [], dateRange, customRa
363367 const totalEstimatedCost = runs . reduce ( ( sum , r ) => sum + Number ( r . estimated_cost_usd || 0 ) , 0 )
364368 const success = runs . filter ( r => getStatus ( r . status ) === 'success' ) . length
365369 const failure = runs . length - success
370+ const totalTokens = totalInput + totalOutput
366371
367372 return {
368373 totalRuns : runs . length ,
369374 totalInputTokens : totalInput ,
370375 totalOutputTokens : totalOutput ,
376+ totalTokens,
371377 totalCost : totalEstimatedCost ,
372378 successCount : success ,
373379 failureCount : failure ,
374380 successRate : runs . length > 0 ? ( success / runs . length ) * 100 : 0 ,
375381 uniqueProjects : new Set ( runs . map ( r => getProjectLabel ( r . project_dir ) ) ) . size ,
376382 uniqueMachines : new Set ( runs . map ( r => getMachineLabel ( r . machine_id ) ) ) . size ,
383+ currentAvgTokensPerRun : runs . length > 0 ? totalTokens / runs . length : 0 ,
377384 }
378385 } , [ detailRuns ] )
379386
380387 const detailDailySeries = useMemo ( ( ) => buildSeriesFromRuns ( detailRuns , 'day' ) , [ detailRuns ] )
381388 const detailHourlySeries = useMemo ( ( ) => buildSeriesFromRuns ( detailRuns , 'hour' ) , [ detailRuns ] )
389+
390+ useEffect ( ( ) => {
391+ let isCancelled = false
392+
393+ const fetchPreviousWindowAvg = async ( ) => {
394+ if ( ! activeSkillName || ! rangeBoundaries ?. startTime ) {
395+ setPreviousAvgTokensPerRun ( null )
396+ setPreviousPeriodRunCount ( 0 )
397+ setIsPreviousAvgLoading ( false )
398+ return
399+ }
400+
401+ const currentStartDate = new Date ( rangeBoundaries . startTime )
402+ if ( Number . isNaN ( currentStartDate . getTime ( ) ) ) {
403+ setPreviousAvgTokensPerRun ( null )
404+ setPreviousPeriodRunCount ( 0 )
405+ setIsPreviousAvgLoading ( false )
406+ return
407+ }
408+
409+ const currentEndDate = rangeBoundaries . endTime ? new Date ( rangeBoundaries . endTime ) : new Date ( )
410+ if ( Number . isNaN ( currentEndDate . getTime ( ) ) ) {
411+ setPreviousAvgTokensPerRun ( null )
412+ setPreviousPeriodRunCount ( 0 )
413+ setIsPreviousAvgLoading ( false )
414+ return
415+ }
416+
417+ const durationMs = currentEndDate . getTime ( ) - currentStartDate . getTime ( )
418+ if ( durationMs <= 0 ) {
419+ setPreviousAvgTokensPerRun ( null )
420+ setPreviousPeriodRunCount ( 0 )
421+ setIsPreviousAvgLoading ( false )
422+ return
423+ }
424+
425+ const prevEnd = new Date ( currentStartDate )
426+ const prevStart = new Date ( currentStartDate . getTime ( ) - durationMs )
427+
428+ setIsPreviousAvgLoading ( true )
429+
430+ const { data, error } = await supabase
431+ . from ( 'skill_runs' )
432+ . select ( 'tokens_used,output_tokens,triggered_at' )
433+ . eq ( 'skill_name' , activeSkillName )
434+ . eq ( 'is_skeleton' , false )
435+ . gte ( 'triggered_at' , prevStart . toISOString ( ) )
436+ . lt ( 'triggered_at' , prevEnd . toISOString ( ) )
437+
438+ if ( isCancelled ) return
439+
440+ if ( error ) {
441+ console . error ( 'Error fetching previous skill window:' , error )
442+ setPreviousAvgTokensPerRun ( null )
443+ setPreviousPeriodRunCount ( 0 )
444+ setIsPreviousAvgLoading ( false )
445+ return
446+ }
447+
448+ const rows = Array . isArray ( data ) ? data : [ ]
449+ if ( rows . length === 0 ) {
450+ setPreviousAvgTokensPerRun ( 0 )
451+ setPreviousPeriodRunCount ( 0 )
452+ setIsPreviousAvgLoading ( false )
453+ return
454+ }
455+
456+ const prevTotalTokens = rows . reduce ( ( sum , row ) => sum + ( row . tokens_used || 0 ) + ( row . output_tokens || 0 ) , 0 )
457+ const prevAvg = rows . length > 0 ? prevTotalTokens / rows . length : 0
458+
459+ setPreviousAvgTokensPerRun ( prevAvg )
460+ setPreviousPeriodRunCount ( rows . length )
461+ setIsPreviousAvgLoading ( false )
462+ }
463+
464+ fetchPreviousWindowAvg ( )
465+
466+ return ( ) => {
467+ isCancelled = true
468+ }
469+ } , [ activeSkillName , rangeBoundaries ] )
382470 const detailTrendSeries = trendTime === 'hour' ? detailHourlySeries : detailDailySeries
383471 const detailHasTokenSignal = detailTrendSeries . some ( p => ( p . input_tokens || 0 ) > 0 || ( p . output_tokens || 0 ) > 0 )
384472 const detailUseRunFallbackSeries = detailTrendSeries . length > 0 && ! detailHasTokenSignal
@@ -392,6 +480,63 @@ function SkillsPanel({ skillRuns = [], skillDailyStats = [], dateRange, customRa
392480 . slice ( 0 , 20 )
393481 } , [ detailRuns ] )
394482
483+ const detailTokenDelta = useMemo ( ( ) => {
484+ const currentAvg = detailSummary . currentAvgTokensPerRun || 0
485+ const previousAvg = typeof previousAvgTokensPerRun === 'number' ? previousAvgTokensPerRun : null
486+
487+ if ( isPreviousAvgLoading ) {
488+ return {
489+ deltaLabel : 'Calculating vs previous...' ,
490+ semanticLabel : '—' ,
491+ tone : 'neutral' ,
492+ }
493+ }
494+
495+ if ( detailSummary . totalRuns === 0 && previousPeriodRunCount === 0 ) {
496+ return {
497+ deltaLabel : '—' ,
498+ semanticLabel : '—' ,
499+ tone : 'neutral' ,
500+ }
501+ }
502+
503+ if ( previousAvg === null ) {
504+ return {
505+ deltaLabel : '—' ,
506+ semanticLabel : '—' ,
507+ tone : 'neutral' ,
508+ }
509+ }
510+
511+ if ( previousAvg > 0 ) {
512+ const delta = currentAvg - previousAvg
513+ const deltaPct = ( delta / previousAvg ) * 100
514+ const pctMagnitude = formatPercent ( Math . abs ( deltaPct ) )
515+ const pctSigned = deltaPct > 0 ? `+${ pctMagnitude } ` : deltaPct < 0 ? `-${ pctMagnitude } ` : pctMagnitude
516+ const arrow = delta > 0 ? '↑' : delta < 0 ? '↓' : '→'
517+
518+ return {
519+ deltaLabel : `${ arrow } ${ pctSigned } vs previous` ,
520+ semanticLabel : delta > 0 ? 'Tốn hơn' : delta < 0 ? 'Tiết kiệm hơn' : 'Không đổi' ,
521+ tone : delta > 0 ? 'higher' : delta < 0 ? 'saving' : 'neutral' ,
522+ }
523+ }
524+
525+ if ( previousAvg === 0 && currentAvg > 0 ) {
526+ return {
527+ deltaLabel : 'New baseline' ,
528+ semanticLabel : 'Tốn hơn' ,
529+ tone : 'higher' ,
530+ }
531+ }
532+
533+ return {
534+ deltaLabel : '—' ,
535+ semanticLabel : '—' ,
536+ tone : 'neutral' ,
537+ }
538+ } , [ detailSummary . currentAvgTokensPerRun , detailSummary . totalRuns , previousAvgTokensPerRun , previousPeriodRunCount , isPreviousAvgLoading ] )
539+
395540 const renderCell = ( r , key , onOpenSkill ) => {
396541 switch ( key ) {
397542 case 'skill_name' :
@@ -651,8 +796,9 @@ function SkillsPanel({ skillRuns = [], skillDailyStats = [], dateRange, customRa
651796 </ div >
652797 < div className = "stat-card" >
653798 < div className = "stat-header" > < span className = "stat-label" > TOKENS</ span > </ div >
654- < div className = "stat-value" > { formatNumber ( detailSummary . totalInputTokens + detailSummary . totalOutputTokens ) } </ div >
799+ < div className = "stat-value" > { formatNumber ( detailSummary . totalTokens ) } </ div >
655800 < div className = "stat-meta" > { formatNumber ( detailSummary . totalInputTokens ) } input · { formatNumber ( detailSummary . totalOutputTokens ) } output</ div >
801+ < div className = "stat-meta" > Avg { formatNumber ( detailSummary . currentAvgTokensPerRun ) } / run · { detailTokenDelta . deltaLabel } · { detailTokenDelta . semanticLabel } </ div >
656802 </ div >
657803 < div className = "stat-card" >
658804 < div className = "stat-header" > < span className = "stat-label" > SUCCESS RATE</ span > </ div >
0 commit comments