Skip to content

Commit b76b7b9

Browse files
rajbosCopilot
andauthored
feat(chart): add rolling average toggle for Total Tokens and Est. Cost views (#836)
Add a '📈 Rolling Avg' button to the chart view controls (only visible when Total Tokens or Est. Cost view is active). When toggled: - Day period → 7-day rolling average - Week period → 4-week rolling average - Month period → 3-month rolling average The chart switches from a bar chart to a smooth line chart showing the rolling average. Summary cards remain unchanged (still show period totals). The chart title updates to reflect the active rolling window. Rolling mode resets automatically when switching to By Model, By Editor, or By Repository views (where it doesn't apply). Rolling state persists across period switches and background data refreshes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f8caeee commit b76b7b9

2 files changed

Lines changed: 94 additions & 11 deletions

File tree

vscode-extension/src/webview/chart/main.ts

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,33 @@ let currentPeriod: ChartPeriod = 'day';
9191
let pendingView: typeof currentView | null = null;
9292
let pendingPeriod: ChartPeriod | null = null;
9393

94+
type DisplayMode = 'actual' | 'rolling';
95+
let currentDisplayMode: DisplayMode = 'actual';
96+
const ROLLING_WINDOW: Record<ChartPeriod, number> = { day: 7, week: 4, month: 3 };
97+
98+
function computeRollingAverage(data: number[], window: number): number[] {
99+
return data.map((_, i) => {
100+
const start = Math.max(0, i - window + 1);
101+
const slice = data.slice(start, i + 1);
102+
return Math.round(slice.reduce((a, b) => a + b, 0) / slice.length);
103+
});
104+
}
105+
106+
function getRollingLabel(): string {
107+
const w = ROLLING_WINDOW[currentPeriod];
108+
const unit = currentPeriod === 'day' ? 'day' : currentPeriod === 'week' ? 'week' : 'month';
109+
return `${w}-${unit} rolling avg`;
110+
}
111+
112+
function getChartTitle(): string {
113+
const periodMeta = PERIOD_LABELS[currentPeriod];
114+
let titleText = currentView === 'cost' ? periodMeta.costTitle : periodMeta.title;
115+
if (currentDisplayMode === 'rolling' && (currentView === 'total' || currentView === 'cost')) {
116+
titleText += ` (${getRollingLabel()})`;
117+
}
118+
return titleText;
119+
}
120+
94121
/** Returns period data for the current period, falling back to legacy flat fields. */
95122
function getActivePeriodData(data: InitialChartData): ChartPeriodData {
96123
if (data.periods) {
@@ -138,7 +165,7 @@ function renderLayout(data: InitialChartData): void {
138165
const header = el('div', 'header');
139166
const headerLeft = el('div', 'header-left');
140167
const icon = el('span', 'header-icon', '📈');
141-
const title = el('span', 'header-title', currentView === 'cost' ? PERIOD_LABELS[currentPeriod].costTitle : PERIOD_LABELS[currentPeriod].title);
168+
const title = el('span', 'header-title', getChartTitle());
142169
title.id = 'chart-title';
143170
headerLeft.append(icon, title);
144171
const buttons = el('div', 'button-row');
@@ -215,7 +242,10 @@ function renderLayout(data: InitialChartData): void {
215242
repoBtn.id = 'view-repository';
216243
const costBtn = el('button', `toggle${currentView === 'cost' ? ' active' : ''}`, '💰 Est. Cost');
217244
costBtn.id = 'view-cost';
218-
toggles.append(totalBtn, modelBtn, editorBtn, repoBtn, costBtn);
245+
const rollingApplicableNow = currentView === 'total' || currentView === 'cost';
246+
const rollingBtn = el('button', `toggle rolling-toggle${currentDisplayMode === 'rolling' ? ' active' : ''}${rollingApplicableNow ? '' : ' hidden'}`, '📈 Rolling Avg');
247+
rollingBtn.id = 'view-rolling';
248+
toggles.append(totalBtn, modelBtn, editorBtn, repoBtn, costBtn, rollingBtn);
219249

220250
const canvasWrap = el('div', 'canvas-wrap');
221251
const canvas = document.createElement('canvas');
@@ -293,7 +323,7 @@ function updateSummaryCards(data: InitialChartData): void {
293323
updateCard('card-total-sessions', null, periodData.totalSessions.toLocaleString());
294324

295325
const title = document.getElementById('chart-title');
296-
if (title) { title.textContent = currentView === 'cost' ? periodMeta.costTitle : periodMeta.title; }
326+
if (title) { title.textContent = getChartTitle(); }
297327

298328
const footer = document.getElementById('chart-footer');
299329
if (footer) {
@@ -348,6 +378,9 @@ function wireInteractions(data: InitialChartData): void {
348378
const btn = document.getElementById(id);
349379
btn?.addEventListener('click', () => { void switchView(view, data); });
350380
});
381+
382+
const rollingToggle = document.getElementById('view-rolling');
383+
rollingToggle?.addEventListener('click', () => { void switchDisplayMode(data); });
351384
}
352385

353386
async function setupChart(canvas: HTMLCanvasElement, data: InitialChartData): Promise<void> {
@@ -405,8 +438,17 @@ async function switchView(view: 'total' | 'model' | 'editor' | 'repository' | 'c
405438
if (currentView === view) {
406439
return;
407440
}
441+
const rollingApplicable = view === 'total' || view === 'cost';
442+
if (!rollingApplicable) {
443+
currentDisplayMode = 'actual';
444+
}
408445
currentView = view;
409446
setActiveView(view);
447+
const rollingBtnEl = document.getElementById('view-rolling');
448+
if (rollingBtnEl) {
449+
rollingBtnEl.classList.toggle('hidden', !rollingApplicable);
450+
rollingBtnEl.classList.toggle('active', rollingApplicable && currentDisplayMode === 'rolling');
451+
}
410452
updateSummaryCards(data);
411453
if (!chart) {
412454
return;
@@ -445,6 +487,27 @@ function setActiveView(view: 'total' | 'model' | 'editor' | 'repository' | 'cost
445487
});
446488
}
447489

490+
function setActiveDisplayMode(mode: DisplayMode): void {
491+
const btn = document.getElementById('view-rolling');
492+
if (!btn) { return; }
493+
btn.classList.toggle('active', mode === 'rolling');
494+
}
495+
496+
async function switchDisplayMode(data: InitialChartData): Promise<void> {
497+
currentDisplayMode = currentDisplayMode === 'actual' ? 'rolling' : 'actual';
498+
setActiveDisplayMode(currentDisplayMode);
499+
updateSummaryCards(data);
500+
if (!chart) { return; }
501+
const canvas = chart.canvas as HTMLCanvasElement | null;
502+
chart.destroy();
503+
if (!canvas) { return; }
504+
const ctx = canvas.getContext('2d');
505+
if (!ctx) { return; }
506+
await loadChartModule();
507+
if (!Chart) { return; }
508+
chart = new Chart(ctx, createConfig(currentView, data));
509+
}
510+
448511
function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost', data: InitialChartData): ChartConfig {
449512
const period = getActivePeriodData(data);
450513

@@ -480,17 +543,23 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
480543
};
481544

482545
if (view === 'total') {
546+
const isRolling = currentDisplayMode === 'rolling';
547+
const rollingLabel = getRollingLabel();
548+
const tokenData = isRolling ? computeRollingAverage(period.tokensData, ROLLING_WINDOW[currentPeriod]) : period.tokensData;
483549
return {
484550
type: 'bar' as const,
485551
data: {
486552
labels: period.labels,
487553
datasets: [
488554
{
489-
label: 'Tokens',
490-
data: period.tokensData,
491-
backgroundColor: 'rgba(54, 162, 235, 0.6)',
555+
label: isRolling ? rollingLabel : 'Tokens',
556+
data: tokenData,
557+
backgroundColor: isRolling ? 'rgba(54, 162, 235, 0.15)' : 'rgba(54, 162, 235, 0.6)',
492558
borderColor: 'rgba(54, 162, 235, 1)',
493-
borderWidth: 1,
559+
borderWidth: isRolling ? 2 : 1,
560+
type: isRolling ? 'line' as const : undefined,
561+
tension: isRolling ? 0.4 : undefined,
562+
fill: isRolling ? false : undefined,
494563
yAxisID: 'y'
495564
},
496565
{
@@ -532,17 +601,23 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
532601
const datasets = view === 'model' ? period.modelDatasets : view === 'repository' ? period.repositoryDatasets : period.editorDatasets;
533602

534603
if (view === 'cost') {
604+
const isRolling = currentDisplayMode === 'rolling';
605+
const rollingLabel = getRollingLabel();
606+
const costData = isRolling ? computeRollingAverage(period.costData, ROLLING_WINDOW[currentPeriod]) : period.costData;
535607
return {
536608
type: 'bar' as const,
537609
data: {
538610
labels: period.labels,
539611
datasets: [
540612
{
541-
label: 'Est. Cost (TBB)',
542-
data: period.costData,
543-
backgroundColor: 'rgba(34, 197, 94, 0.6)',
613+
label: isRolling ? `${rollingLabel} (TBB)` : 'Est. Cost (TBB)',
614+
data: costData,
615+
backgroundColor: isRolling ? 'rgba(34, 197, 94, 0.15)' : 'rgba(34, 197, 94, 0.6)',
544616
borderColor: 'rgba(34, 197, 94, 1)',
545-
borderWidth: 1,
617+
borderWidth: isRolling ? 2 : 1,
618+
type: isRolling ? 'line' as const : undefined,
619+
tension: isRolling ? 0.4 : undefined,
620+
fill: isRolling ? false : undefined,
546621
yAxisID: 'y'
547622
}
548623
]

vscode-extension/src/webview/chart/styles.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,11 @@ body {
190190
.footer em {
191191
color: var(--text-secondary);
192192
}
193+
194+
.hidden {
195+
display: none !important;
196+
}
197+
198+
.rolling-toggle {
199+
margin-left: 12px;
200+
}

0 commit comments

Comments
 (0)