Skip to content

Commit edf25de

Browse files
committed
fix(skills): add avg token delta in skill detail
Pass range boundaries from App to SkillsPanel so the modal can query an equal-length previous window. Compute avg tokens per run and render period-over-period delta with spending/saving semantics.
1 parent 36ee4fe commit edf25de

File tree

3 files changed

+152
-3
lines changed

3 files changed

+152
-3
lines changed

frontend/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ function App() {
262262
const [lastUpdated, setLastUpdated] = useState(null)
263263
const [dateRange, setDateRange] = useState('today')
264264
const [customRange, setCustomRange] = useState({ startDate: null, endDate: null })
265+
const rangeBoundaries = useMemo(() => getDateBoundaries(dateRange, customRange), [dateRange, customRange])
265266

266267
const [credentialData, setCredentialData] = useState(null)
267268
const [credentialTimeSeries, setCredentialTimeSeries] = useState({ byDay: [], byHour: [], meta: {} })
@@ -1456,6 +1457,7 @@ function App() {
14561457
dateRange={dateRange}
14571458
onDateRangeChange={handleDateRangeChange}
14581459
customRange={customRange}
1460+
rangeBoundaries={rangeBoundaries}
14591461
onCustomRangeApply={handleCustomRangeApply}
14601462
endpointUsage={endpointUsage}
14611463
credentialData={credentialData}

frontend/src/components/Dashboard.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ const TREND_CONFIG = {
218218
cost: { stroke: '#f59e0b', name: 'Cost' },
219219
}
220220

221-
function Dashboard({ stats, dailyStats, modelUsage, hourlyStats, loading, isRefreshing, lastUpdated, dateRange, onDateRangeChange, customRange, onCustomRangeApply, endpointUsage: rawEndpointUsage, credentialData, credentialTimeSeries, credentialLoading, credentialSetupRequired, skillRuns, skillDailyStats, appLogs, onClearAllLogs, onLogout }) {
221+
function Dashboard({ stats, dailyStats, modelUsage, hourlyStats, loading, isRefreshing, lastUpdated, dateRange, onDateRangeChange, customRange, rangeBoundaries, onCustomRangeApply, endpointUsage: rawEndpointUsage, credentialData, credentialTimeSeries, credentialLoading, credentialSetupRequired, skillRuns, skillDailyStats, appLogs, onClearAllLogs, onLogout }) {
222222
// Auto-select time range based on dateRange: hour for today/yesterday, day for longer ranges
223223
const defaultTimeRange = (dateRange === 'today' || dateRange === 'yesterday') ? 'hour' : 'day'
224224

@@ -1744,6 +1744,7 @@ function Dashboard({ stats, dailyStats, modelUsage, hourlyStats, loading, isRefr
17441744
skillDailyStats={skillDailyStats}
17451745
dateRange={dateRange}
17461746
customRange={customRange}
1747+
rangeBoundaries={rangeBoundaries}
17471748
isDarkMode={isDarkMode}
17481749
/>
17491750
) : activeTab === 'logs' ? (

frontend/src/components/SkillsPanel.jsx

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useMemo, useState } from 'react'
22
import { AreaChart, Area, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
3+
import { supabase } from '../lib/supabase'
34
import { CHART_COLORS, CHART_TYPOGRAPHY } from '../lib/brandColors'
45
import ChartDialog from './ChartDialog'
56
import 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

Comments
 (0)