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
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1661,7 +1661,7 @@ export async function run(cliArgs: Record<string, unknown>): Promise<void> {

// Merge and aggregate
const mergedDaily = mergeProviderData(providerDataList);
const stats = aggregate(mergedDaily, dateRange.until);
const stats = aggregate(mergedDaily, dateRange.until, dateRange);

// Force --more when --advisor is used (needs event data)
const needsMore =
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ async function loadAndAggregate(
}

const mergedDaily = mergeProviderData(providerDataList);
const stats = aggregate(mergedDaily, range.until);
const stats = aggregate(mergedDaily, range.until, range);
stats.costCompleteness = mergeCostCompleteness(providerDataList);

return { data: providerDataList, stats };
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/aggregation/aggregate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AggregatedStats, DailyUsage } from '../types';
import { ONE_DAY_MS, dateToUtcMs } from '../date-utils';
import type { AggregatedStats, DailyUsage, DateRange } from '../types';
import { ONE_DAY_MS, dateToUtcMs, inclusiveDaySpan } from '../date-utils';
import { buildDailyCostCompleteness } from '../cost-completeness';
import { calculateStreaks } from './streaks';
import { rollingWindow } from './rolling-window';
Expand All @@ -19,6 +19,7 @@ const ROLLING_WINDOW_DAYS = 30;
export function aggregate(
daily: DailyUsage[],
referenceDate: string,
range?: DateRange,
): AggregatedStats {
const streaks = calculateStreaks(daily, referenceDate);
const rolling30 = rollingWindow(daily, 30, referenceDate);
Expand All @@ -41,7 +42,7 @@ export function aggregate(

const rolling30dTopModel = computeRolling30dTopModel(daily, referenceDate);
const activeDays = daily.length;
const totalDays = computeTotalDays(daily);
const totalDays = range ? computeRangeDays(range) : computeTotalDays(daily);
const averages = calculateAverages(daily, totalDays);
const costCompleteness = buildDailyCostCompleteness(daily);

Expand Down Expand Up @@ -69,6 +70,10 @@ export function aggregate(
};
}

function computeRangeDays(range: DateRange): number {
return Math.max(0, inclusiveDaySpan(range.since, range.until));
}

/**
* Find the most-used model in the rolling 30-day window.
*/
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/aggregation/aggregation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import type {
DateRange,
DailyUsage,
ProviderData,
ProviderColors,
Expand Down Expand Up @@ -452,6 +453,21 @@ describe('aggregate', () => {
expect(result.rolling30dTopModel).toBe('claude-3-opus');
});

test('uses the selected date range when averaging sparse data', () => {
const range: DateRange = { since: '2025-01-01', until: '2025-01-30' };
const days = [
makeDay('2025-01-29', 300, 0.03),
makeDay('2025-01-30', 600, 0.06),
];

const result = aggregate(days, range.until, range);

expect(result.activeDays).toBe(2);
expect(result.totalDays).toBe(30);
expect(result.averageDailyTokens).toBe(30);
expect(result.averageDailyCost).toBeCloseTo(0.003);
});

test('rolling30dTopModel returns model with most tokens in 30-day window', () => {
const days = [
makeDay('2025-01-28', 100, 0.01, {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/aggregation/compare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,20 @@ describe('compareRanges', () => {
expect(result.deltas.activeDays).toBe(4 - 3);
});

it('uses full period lengths for sparse average daily deltas', () => {
const sparseDaily: DailyUsage[] = [
makeDailyUsage('2026-01-31', 3100, 0.31),
makeDailyUsage('2026-02-28', 2800, 0.28),
];
const result = compareRanges(sparseDaily, rangeA, rangeB);

expect(result.periodA.stats.totalDays).toBe(31);
expect(result.periodB.stats.totalDays).toBe(28);
expect(result.periodA.stats.averageDailyTokens).toBe(100);
expect(result.periodB.stats.averageDailyTokens).toBe(100);
expect(result.deltas.averageDailyTokens).toBe(0);
});

it('correctly filters data to each range', () => {
const result = compareRanges(daily, rangeA, rangeB);

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/aggregation/compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ export function compareRanges(
const dailyA = filterByRange(daily, rangeA);
const dailyB = filterByRange(daily, rangeB);

const statsA = aggregate(dailyA, rangeA.until);
const statsB = aggregate(dailyB, rangeB.until);
const statsA = aggregate(dailyA, rangeA.until, rangeA);
const statsB = aggregate(dailyB, rangeB.until, rangeB);

const deltas = computeDeltas(statsA, statsB);

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/resources/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export async function handleOverview(registry: ProviderRegistry): Promise<string
}

const merged = mergeProviderData(data);
const stats = aggregate(merged, range.until);
const stats = aggregate(merged, range.until, range);

return JSON.stringify(
{
Expand Down
18 changes: 18 additions & 0 deletions packages/mcp/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,24 @@ describe('MCP Server', () => {
expect(typeof parsed.deltas.cost).toBe('number');
});

it('rejects invalid compare_periods ranges', async () => {
const { client } = await createConnectedClient();

const result = await client.callTool({
name: 'compare_periods',
arguments: {
current_since: '2025-02-30',
current_until: '2025-01-31',
previous_since: '2024-12-01',
previous_until: '2024-12-31',
},
});

expect(result.isError).toBe(true);
const content = result.content as Array<{ type: string; text: string }>;
expect(content[0]!.text).toContain('Invalid since date');
});

it('calls get_efficiency_advice and returns advisor report', async () => {
const { client } = await createConnectedClient();

Expand Down
30 changes: 30 additions & 0 deletions packages/mcp/src/shared/date-range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'bun:test';
import { resolveRange } from './date-range';

describe('resolveRange', () => {
it('computes an inclusive trailing range from days and until', () => {
expect(resolveRange({ days: 7, until: '2026-03-14' })).toEqual({
since: '2026-03-08',
until: '2026-03-14',
});
});

it('rejects non-positive day counts', () => {
expect(() => resolveRange({ days: 0, until: '2026-03-14' })).toThrow(
'days must be a positive number',
);
});

it('rejects invalid calendar dates', () => {
expect(() => resolveRange({ since: '2026-02-30', until: '2026-03-14' })).toThrow(
'Invalid since date',
);
expect(() => resolveRange({ until: '2026-13-01' })).toThrow('Invalid until date');
});

it('rejects ranges where since is after until', () => {
expect(() => resolveRange({ since: '2026-03-15', until: '2026-03-14' })).toThrow(
'since must not be after until',
);
});
});
41 changes: 35 additions & 6 deletions packages/mcp/src/shared/date-range.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import type { DateRange } from '@tokenleak/core';
import { DEFAULT_DAYS, formatDateStringUtc, ONE_DAY_MS } from '@tokenleak/core';
import { DEFAULT_DAYS, getTodayLocal, shiftDateStringLocal } from '@tokenleak/core';

const DATE_FORMAT = /^\d{4}-\d{2}-\d{2}$/;

function isValidDate(date: string): boolean {
if (!DATE_FORMAT.test(date)) {
return false;
}
const parsed = new Date(`${date}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === date;
}

export function assertValidDate(label: 'since' | 'until', date: string): void {
if (!isValidDate(date)) {
throw new Error(`Invalid ${label} date: "${date}". Use YYYY-MM-DD format.`);
}
}

export function validateRange(range: DateRange): DateRange {
assertValidDate('since', range.since);
assertValidDate('until', range.until);
if (range.since > range.until) {
throw new Error('since must not be after until');
}
return range;
}

/**
* Resolve a date range from optional `days`, `since`, and `until` parameters.
Expand All @@ -12,16 +37,20 @@ export function resolveRange(
args: { days?: number; since?: string; until?: string },
defaultDays: number = DEFAULT_DAYS,
): DateRange {
const now = new Date();
const untilDate = args.until ?? formatDateStringUtc(now);
const untilDate = args.until ?? getTodayLocal();
const days = args.days ?? defaultDays;

if (!Number.isFinite(days) || days <= 0) {
throw new Error('days must be a positive number');
}

assertValidDate('until', untilDate);

if (args.since) {
return { since: args.since, until: untilDate };
return validateRange({ since: args.since, until: untilDate });
}

const sinceMs = Date.parse(`${untilDate}T00:00:00Z`) - (days - 1) * ONE_DAY_MS;
const sinceDate = formatDateStringUtc(new Date(sinceMs));
const sinceDate = shiftDateStringLocal(untilDate, -(days - 1));

return { since: sinceDate, until: untilDate };
}
16 changes: 9 additions & 7 deletions packages/mcp/src/tools/compare-periods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@tokenleak/core';
import type { DateRange } from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { assertValidDate, validateRange } from '../shared/date-range.js';
import { loadProviderData } from '../shared/provider-load.js';

async function loadAndAggregate(
Expand All @@ -17,7 +18,7 @@ async function loadAndAggregate(
) {
const { data, warnings } = await loadProviderData(providers, range);
const merged = mergeProviderData(data);
const stats = aggregate(merged, range.until);
const stats = aggregate(merged, range.until, range);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compare-periods still constructs currentRange/previousRange directly instead of going through the new validation path, so invalid dates or since > until can flow into aggregation here and return misleading stats rather than an MCP error. Please route these ranges through the shared validator or equivalent before loading/aggregating.

return { stats, warnings };
}

Expand All @@ -31,22 +32,23 @@ export async function handleComparePeriods(
registry: ProviderRegistry,
) {
try {
const currentRange: DateRange = {
const currentRange = validateRange({
since: args.current_since,
until: args.current_until ?? getTodayLocal(),
};
});

let previousRange: DateRange;
if (args.previous_since && args.previous_until) {
previousRange = { since: args.previous_since, until: args.previous_until };
previousRange = validateRange({ since: args.previous_since, until: args.previous_until });
} else if (args.previous_since) {
previousRange = { since: args.previous_since, until: currentRange.since };
previousRange = validateRange({ since: args.previous_since, until: currentRange.since });
} else if (args.previous_until) {
assertValidDate('until', args.previous_until);
const currentDays = inclusiveDaySpan(currentRange.since, currentRange.until);
previousRange = {
previousRange = validateRange({
since: shiftDateStringLocal(args.previous_until, -(currentDays - 1)),
until: args.previous_until,
};
});
} else {
previousRange = computePreviousPeriod(currentRange);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/get-cost-breakdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function handleGetCostBreakdown(
}

const merged = mergeProviderData(data);
const stats = aggregate(merged, range.until);
const stats = aggregate(merged, range.until, range);

return {
content: [
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/get-efficiency-advice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function handleGetEfficiencyAdvice(
}

const merged = mergeProviderData(data);
const stats = aggregate(merged, range.until);
const stats = aggregate(merged, range.until, range);
const more = buildMoreStats(data, range);

const output = {
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/get-streaks-and-habits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export async function handleGetStreaksAndHabits(
}

const merged = mergeProviderData(data);
const stats = aggregate(merged, range.until);
const stats = aggregate(merged, range.until, range);
const more = buildMoreStats(data, range);

return {
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/get-usage-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function handleGetUsageSummary(
}

const merged = mergeProviderData(data);
const stats = aggregate(merged, range.until);
const stats = aggregate(merged, range.until, range);

return {
content: [
Expand Down
13 changes: 13 additions & 0 deletions packages/renderers/src/live/__tests__/live-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ describe('startLiveServer', () => {
expect(html).toContain('TOTAL TOKENS');
});

it('HTML renders date range length inclusively', async () => {
const { port, stop } = await startLiveServer(
createOutput({ dateRange: { since: '2026-03-11', until: '2026-03-11' } }),
{ ...createRenderOptions(), port: 0 },
);
cleanups.push(stop);

const res = await fetch(`http://localhost:${port}/`);
const html = await res.text();

expect(html).toContain('1 DAYS');
});

it('shuts down cleanly', async () => {
const { port, stop } = await startLiveServer(
createOutput(),
Expand Down
8 changes: 2 additions & 6 deletions packages/renderers/src/live/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ProviderData,
ProviderColors,
} from '@tokenleak/core';
import { inclusiveDaySpan } from '@tokenleak/core';
import { formatNumber, formatCost } from '../svg/utils';
import { buildHeatmapModel } from '../shared/heatmap-model';
import {
Expand All @@ -16,10 +17,6 @@ import {
MODEL_PERCENT_WIDTH,
} from '../card/layout';

const MONTH_NAMES = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
const MONTH_NAMES_FULL = [
'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN',
'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC',
Expand All @@ -28,8 +25,7 @@ const MONTH_NAMES_FULL = [
function formatDateRange(since: string, until: string): string {
const s = new Date(since + 'T00:00:00Z');
const u = new Date(until + 'T00:00:00Z');
const diffMs = u.getTime() - s.getTime();
const days = Math.round(diffMs / (1000 * 60 * 60 * 24));
const days = inclusiveDaySpan(since, until);
const sMonth = MONTH_NAMES_FULL[s.getUTCMonth()] ?? '';
const uMonth = MONTH_NAMES_FULL[u.getUTCMonth()] ?? '';
return `${sMonth} ${s.getUTCFullYear()} &mdash; ${uMonth} ${u.getUTCFullYear()} &middot; ${days} DAYS`;
Expand Down
9 changes: 9 additions & 0 deletions packages/renderers/src/png/__tests__/terminal-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ describe('renderTerminalCardSvg', () => {
expect(svg).toContain('DAYS');
});

it('renders an inclusive single-day date range', () => {
const svg = renderTerminalCardSvg(
createOutput({ dateRange: { since: '2026-03-11', until: '2026-03-11' } }),
options,
);

expect(svg).toContain('1 DAYS');
});

it('contains heatmap cells (rect elements)', () => {
const svg = renderTerminalCardSvg(output, options);
const rectCount = (svg.match(/<rect /g) ?? []).length;
Expand Down
Loading
Loading