Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 58 additions & 5 deletions vscode-extension/src/webview/chart/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { el, createButton } from '../shared/domUtils';
import { BUTTONS } from '../shared/buttonConfig';
import { formatCompact, setCompactNumbers } from '../shared/formatUtils';
import { wireExtensionPointButtons } from '../shared/extensionPoints';
import { getCurrentPeriodFraction, computeProjectionExtra } from './projectionUtils';
// CSS imported as text via esbuild
import themeStyles from '../shared/theme.css';
import styles from './styles.css';
Expand Down Expand Up @@ -32,7 +33,7 @@ type ChartPeriodData = {
avgCostPerPeriod: number;
};

type ChartPeriod = 'day' | 'week' | 'month';
type ChartPeriod = import('./projectionUtils').ChartPeriod;

type InitialChartData = {
labels: string[];
Expand Down Expand Up @@ -521,6 +522,13 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
// Make grid lines very subtle with low opacity
const gridColor = 'rgba(128, 128, 128, 0.15)';

// Projection labels per period
const PROJECTION_LABELS: Record<ChartPeriod, string> = {
day: '📈 Projected (today)',
week: '📈 Projected (this week)',
month: '📈 Projected (this month)',
};

const baseOptions = {
responsive: true,
maintainAspectRatio: false,
Expand All @@ -546,6 +554,20 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
const isRolling = currentDisplayMode === 'rolling';
const rollingLabel = getRollingLabel();
const tokenData = isRolling ? computeRollingAverage(period.tokensData, ROLLING_WINDOW[currentPeriod]) : period.tokensData;

// Projection: only in actual (non-rolling) mode, for the last bar (current period)
const lastIdx = period.tokensData.length - 1;
const fraction = getCurrentPeriodFraction(currentPeriod);
const projExtra = !isRolling && lastIdx >= 0 ? computeProjectionExtra(period.tokensData[lastIdx], fraction) : null;
const projDataset = projExtra !== null ? [{
label: PROJECTION_LABELS[currentPeriod],
data: period.tokensData.map((_: number, i: number) => i === lastIdx ? Math.round(projExtra) : 0),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 0.5)',
borderWidth: 1,
yAxisID: 'y'
}] : [];

return {
type: 'bar' as const,
data: {
Expand All @@ -562,6 +584,7 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
fill: isRolling ? false : undefined,
yAxisID: 'y'
},
...projDataset,
{
label: 'Sessions',
data: period.sessionsData,
Expand All @@ -576,8 +599,9 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
options: {
...baseOptions,
scales: {
...baseOptions.scales,
x: { stacked: true, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 11 } } },
y: {
stacked: true,
type: 'linear' as const,
display: true,
position: 'left' as const,
Expand All @@ -604,6 +628,20 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
const isRolling = currentDisplayMode === 'rolling';
const rollingLabel = getRollingLabel();
const costData = isRolling ? computeRollingAverage(period.costData, ROLLING_WINDOW[currentPeriod]) : period.costData;

// Projection for cost: only in actual (non-rolling) mode
const lastIdx = period.costData.length - 1;
const fraction = getCurrentPeriodFraction(currentPeriod);
const projExtra = !isRolling && lastIdx >= 0 ? computeProjectionExtra(period.costData[lastIdx], fraction) : null;
const projDataset = projExtra !== null ? [{
label: PROJECTION_LABELS[currentPeriod],
data: period.costData.map((_: number, i: number) => i === lastIdx ? projExtra : 0),
backgroundColor: 'rgba(34, 197, 94, 0.2)',
borderColor: 'rgba(34, 197, 94, 0.5)',
borderWidth: 1,
yAxisID: 'y'
}] : [];

return {
type: 'bar' as const,
data: {
Expand All @@ -619,7 +657,8 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
tension: isRolling ? 0.4 : undefined,
fill: isRolling ? false : undefined,
yAxisID: 'y'
}
},
...projDataset
]
},
options: {
Expand All @@ -634,8 +673,9 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
}
},
scales: {
x: { grid: { color: gridColor }, ticks: { color: textColor, font: { size: 11 } } },
x: { stacked: true, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 11 } } },
y: {
stacked: true,
type: 'linear' as const,
display: true,
position: 'left' as const,
Expand All @@ -648,6 +688,19 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'
};
}

// Stacked views: model / editor / repository
// Compute total-token projection for the last bar and add a single "Projected" segment on top.
const lastIdx = period.tokensData.length - 1;
const fraction = getCurrentPeriodFraction(currentPeriod);
const projExtra = lastIdx >= 0 ? computeProjectionExtra(period.tokensData[lastIdx], fraction) : null;
const projDataset = projExtra !== null ? [{
label: PROJECTION_LABELS[currentPeriod],
data: period.tokensData.map((_: number, i: number) => i === lastIdx ? Math.round(projExtra) : 0),
backgroundColor: 'rgba(200, 200, 200, 0.25)',
borderColor: 'rgba(200, 200, 200, 0.5)',
borderWidth: 1,
}] : [];

// Add sessions line as an overlay on all stacked views
const sessionsDataset = {
label: 'Sessions',
Expand All @@ -662,7 +715,7 @@ function createConfig(view: 'total' | 'model' | 'editor' | 'repository' | 'cost'

return {
type: 'bar' as const,
data: { labels: period.labels, datasets: [...datasets, sessionsDataset] },
data: { labels: period.labels, datasets: [...datasets, ...projDataset, sessionsDataset] },
options: {
...baseOptions,
plugins: {
Expand Down
42 changes: 42 additions & 0 deletions vscode-extension/src/webview/chart/projectionUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export type ChartPeriod = 'day' | 'week' | 'month';

/**
* Returns the fraction (0–1) of the current period that has elapsed.
* Uses minute-level precision.
*
* @param period The chart period type.
* @param now Override the current date/time (defaults to new Date()). Used in tests.
*/
export function getCurrentPeriodFraction(period: ChartPeriod, now?: Date): number {
const d = now ?? new Date();
const dayFrac = (d.getHours() * 60 + d.getMinutes()) / (24 * 60);

if (period === 'day') {
return Math.max(0, Math.min(1, dayFrac));
}
if (period === 'week') {
// ISO week: Mon=0, Tue=1, ... Sun=6
const isoWeekDay = (d.getDay() + 6) % 7;
return Math.max(0, Math.min(1, (isoWeekDay + dayFrac) / 7));
}
// month
const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
return Math.max(0, Math.min(1, (d.getDate() - 1 + dayFrac) / daysInMonth));
}

/**
* Computes the additional (projected remaining) value to show on top of the current period bar.
* Returns null when projection is not applicable:
* - no actual usage yet (actual <= 0)
* - period barely started (fraction < 1%)
* - period essentially complete (fraction >= 99.5%)
* - projected extra would round to zero
*
* @param actual Actual value so far for the current period.
* @param fraction Fraction of the period elapsed (0–1) from getCurrentPeriodFraction.
*/
export function computeProjectionExtra(actual: number, fraction: number): number | null {
if (actual <= 0 || fraction < 0.01 || fraction >= 0.995) { return null; }
const extra = actual / fraction - actual;
return extra > 0 ? extra : null;
}
129 changes: 129 additions & 0 deletions vscode-extension/test/unit/webview-chart-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import test from 'node:test';
import * as assert from 'node:assert/strict';

import { getCurrentPeriodFraction, computeProjectionExtra } from '../../src/webview/chart/projectionUtils';

// ── getCurrentPeriodFraction – day ────────────────────────────────────────────

test('getCurrentPeriodFraction day: midnight returns 0', () => {
const midnight = new Date(2025, 4, 15, 0, 0, 0); // May 15, 00:00
assert.equal(getCurrentPeriodFraction('day', midnight), 0);
});

test('getCurrentPeriodFraction day: noon returns ~0.5', () => {
const noon = new Date(2025, 4, 15, 12, 0, 0);
const frac = getCurrentPeriodFraction('day', noon);
assert.ok(Math.abs(frac - 0.5) < 0.001, `Expected ~0.5, got ${frac}`);
});

test('getCurrentPeriodFraction day: end of day returns ~1', () => {
const almostMidnight = new Date(2025, 4, 15, 23, 59, 0);
const frac = getCurrentPeriodFraction('day', almostMidnight);
assert.ok(frac > 0.99 && frac < 1, `Expected close to 1, got ${frac}`);
});

// ── getCurrentPeriodFraction – week ──────────────────────────────────────────

test('getCurrentPeriodFraction week: Monday midnight returns 0', () => {
// May 12, 2025 is a Monday
const mondayMidnight = new Date(2025, 4, 12, 0, 0, 0);
const frac = getCurrentPeriodFraction('week', mondayMidnight);
assert.equal(frac, 0);
});

test('getCurrentPeriodFraction week: Monday noon returns ~1/14', () => {
const mondayNoon = new Date(2025, 4, 12, 12, 0, 0); // Mon at noon
const frac = getCurrentPeriodFraction('week', mondayNoon);
const expected = 0.5 / 7;
assert.ok(Math.abs(frac - expected) < 0.001, `Expected ~${expected}, got ${frac}`);
});

test('getCurrentPeriodFraction week: Thursday midnight returns ~3/7', () => {
// May 15, 2025 is a Thursday (isoWeekDay = 3)
const thursdayMidnight = new Date(2025, 4, 15, 0, 0, 0);
const frac = getCurrentPeriodFraction('week', thursdayMidnight);
const expected = 3 / 7;
assert.ok(Math.abs(frac - expected) < 0.001, `Expected ~${expected}, got ${frac}`);
});

test('getCurrentPeriodFraction week: Sunday end returns close to 1', () => {
// May 18, 2025 is a Sunday (isoWeekDay = 6)
const sundayAlmostEnd = new Date(2025, 4, 18, 23, 59, 0);
const frac = getCurrentPeriodFraction('week', sundayAlmostEnd);
assert.ok(frac > 0.99 && frac <= 1, `Expected close to 1, got ${frac}`);
});

// ── getCurrentPeriodFraction – month ─────────────────────────────────────────

test('getCurrentPeriodFraction month: 1st midnight returns 0', () => {
const firstMidnight = new Date(2025, 4, 1, 0, 0, 0); // May 1
const frac = getCurrentPeriodFraction('month', firstMidnight);
assert.equal(frac, 0);
});

test('getCurrentPeriodFraction month: 15th of 31-day month midnight returns ~14/31', () => {
// May has 31 days; May 15 midnight = 14 full days elapsed
const may15Midnight = new Date(2025, 4, 15, 0, 0, 0);
const frac = getCurrentPeriodFraction('month', may15Midnight);
const expected = 14 / 31;
assert.ok(Math.abs(frac - expected) < 0.001, `Expected ~${expected}, got ${frac}`);
});

test('getCurrentPeriodFraction month: last day of 30-day month near end is close to 1', () => {
// June has 30 days; June 30, 23:59
const lastDay = new Date(2025, 5, 30, 23, 59, 0);
const frac = getCurrentPeriodFraction('month', lastDay);
assert.ok(frac > 0.99 && frac <= 1, `Expected close to 1, got ${frac}`);
});

// ── computeProjectionExtra ────────────────────────────────────────────────────

test('computeProjectionExtra: returns null when actual is 0', () => {
assert.equal(computeProjectionExtra(0, 0.5), null);
});

test('computeProjectionExtra: returns null when fraction is below threshold', () => {
assert.equal(computeProjectionExtra(1000, 0.005), null);
});

test('computeProjectionExtra: returns null when fraction is above threshold (period complete)', () => {
assert.equal(computeProjectionExtra(1000, 0.999), null);
});

test('computeProjectionExtra: returns correct extra for 50% elapsed', () => {
// 1000 tokens at 50% → projected 2000 total → extra = 1000
const extra = computeProjectionExtra(1000, 0.5);
assert.equal(extra, 1000);
});

test('computeProjectionExtra: returns correct extra for 25% elapsed', () => {
// 500 tokens at 25% → projected 2000 total → extra = 1500
const extra = computeProjectionExtra(500, 0.25);
assert.equal(extra, 1500);
});

test('computeProjectionExtra: matches user formula (15th of 31-day month)', () => {
// User formula: sum/dayOfMonth * daysInMonth - sum
// At midnight on May 15: 14 full days elapsed out of 31 → fraction = 14/31
const fraction = 14 / 31;
const actual = 7000;
const extra = computeProjectionExtra(actual, fraction);
// projected = 7000 / (14/31) = 7000 * 31/14 = 15500; extra = 15500 - 7000 = 8500
const expected = Math.round(actual / fraction - actual);
assert.equal(extra, expected);
});

test('computeProjectionExtra: returns a positive float for tiny actual at 99% elapsed', () => {
// fraction = 0.99 (just under threshold), actual = 0.001 → returns a tiny positive float
const extra = computeProjectionExtra(0.001, 0.99);
// 0.001 / 0.99 - 0.001 ≈ 0.00001 > 0 → non-null
assert.ok(extra !== null && extra > 0, `Expected a small positive value, got ${extra}`);
});

test('computeProjectionExtra: returns positive value for realistic token counts', () => {
// 10k tokens at 1/3 of the day
const extra = computeProjectionExtra(10000, 1 / 3);
assert.ok(extra !== null && extra > 0, `Expected positive extra, got ${extra}`);
// projected = 30000; extra = 20000
assert.equal(extra, 20000);
});
Loading