mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(bar): add calendar month-to-date spend to analytics
monthToDate (calendar 1st-of-month to now, local) on BarAnalytics in both compute paths, kept distinct from the rolling last30d so a fresh month resets toward zero. Feeds the monthly-spend alert and the month-spend glance without a rolling-window false breach.
This commit is contained in:
@@ -54,6 +54,12 @@ export interface BarAnalytics {
|
||||
today: BarAnalyticsWindow;
|
||||
last7d: BarAnalyticsWindow;
|
||||
last30d: BarAnalyticsWindow;
|
||||
/**
|
||||
* Honest calendar month-to-date (1st of the current local month → now), NOT a
|
||||
* rolling 30 days. A fresh month resets this toward ~0 even when `last30d`
|
||||
* stays populated, so a monthly-cap alert measures the real billing month.
|
||||
*/
|
||||
monthToDate: BarAnalyticsWindow;
|
||||
/** Lifetime totals across every record in the snapshot. */
|
||||
allTime: BarAnalyticsWindow;
|
||||
/** Oldest → newest, exactly 30 entries (zero-filled), for the sparkline. */
|
||||
@@ -94,6 +100,17 @@ function localDayKey(d: Date): string {
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-time YYYY-MM key for a Date. Local (not a UTC ISO slice) so it matches
|
||||
* the local-day semantics of `dayDelta`/`localDayKey` — a record near midnight
|
||||
* lands in the same month the user sees on their calendar.
|
||||
*/
|
||||
function localMonthKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
/** Whole-day difference (a - b) in local days, via midnight-anchored dates. */
|
||||
function dayDelta(a: Date, b: Date): number {
|
||||
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||||
@@ -112,8 +129,12 @@ export function computeBarAnalytics(
|
||||
const today: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const monthToDate: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
|
||||
// Current local calendar month — records keyed to it feed month-to-date.
|
||||
const currentMonth = localMonthKey(now);
|
||||
|
||||
// Seed the sparkline with the trailing 7 local days (zero-filled, ordered).
|
||||
const dayBuckets = new Map<string, BarAnalyticsDay>();
|
||||
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
|
||||
@@ -162,6 +183,11 @@ export function computeBarAnalytics(
|
||||
allTime.requests += requests;
|
||||
bump(modelAll, detail.model, cost, requests);
|
||||
|
||||
if (localMonthKey(ts) === currentMonth) {
|
||||
monthToDate.cost += cost;
|
||||
monthToDate.requests += requests;
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
today.cost += cost;
|
||||
today.requests += requests;
|
||||
@@ -202,6 +228,7 @@ export function computeBarAnalytics(
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
monthToDate,
|
||||
allTime,
|
||||
byDay: Array.from(dayBuckets.values()),
|
||||
topModels,
|
||||
@@ -255,8 +282,13 @@ export function computeBarAnalyticsFromDaily(
|
||||
const today: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const monthToDate: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
|
||||
// Daily keys (YYYY-MM-DD) and hourly keys (YYYY-MM-DD HH:00) are already local,
|
||||
// so slice(0,7) yields the local YYYY-MM to compare against the current month.
|
||||
const currentMonth = localMonthKey(now);
|
||||
|
||||
const dayBuckets = new Map<string, BarAnalyticsDay>();
|
||||
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||
@@ -309,6 +341,8 @@ export function computeBarAnalyticsFromDaily(
|
||||
}
|
||||
if (cost > 0) touchActivity(d.date);
|
||||
|
||||
if (d.date.slice(0, 7) === currentMonth) monthToDate.cost += cost;
|
||||
|
||||
if (delta === 0) today.cost += cost;
|
||||
if (delta < 7) last7d.cost += cost;
|
||||
if (delta < 30) {
|
||||
@@ -338,6 +372,8 @@ export function computeBarAnalyticsFromDaily(
|
||||
bumpSurface(surfaceAll, source, 0, requests);
|
||||
touchActivity(dayKey);
|
||||
|
||||
if (h.hour.slice(0, 7) === currentMonth) monthToDate.requests += requests;
|
||||
|
||||
if (delta === 0) today.requests += requests;
|
||||
if (delta < 7) last7d.requests += requests;
|
||||
if (delta < 30) {
|
||||
@@ -369,6 +405,7 @@ export function computeBarAnalyticsFromDaily(
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
monthToDate,
|
||||
allTime,
|
||||
byDay: Array.from(dayBuckets.values()),
|
||||
topModels,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { computeBarAnalytics } from '../../../src/web-server/usage/bar-analytics';
|
||||
import {
|
||||
computeBarAnalytics,
|
||||
computeBarAnalyticsFromDaily,
|
||||
} from '../../../src/web-server/usage/bar-analytics';
|
||||
import type { CliproxyUsageHistoryDetail } from '../../../src/web-server/usage/cliproxy-usage-transformer';
|
||||
import type { DailyUsage, HourlyUsage } from '../../../src/web-server/usage/types';
|
||||
|
||||
const NOW = new Date('2026-06-08T12:00:00-04:00');
|
||||
|
||||
@@ -132,4 +136,100 @@ describe('computeBarAnalytics', () => {
|
||||
expect(a.topModelsWindow).toBe('all');
|
||||
expect(a.topModels[0].model).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('sums monthToDate from only current-calendar-month records, even when prior-month data is inside the rolling 30d', () => {
|
||||
// NOW is 2026-06-08. A 2026-05-25 record is 14 days ago: inside last30d but
|
||||
// in the PRIOR calendar month, so it must NOT count toward June MTD.
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ timestamp: '2026-06-02T10:00:00-04:00', cost: 3, requestCount: 2 }), // June
|
||||
detail({ timestamp: '2026-06-08T09:00:00-04:00', cost: 4, requestCount: 1 }), // June (today)
|
||||
detail({ timestamp: '2026-05-25T10:00:00-04:00', cost: 5, requestCount: 9 }), // May, within 30d
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.monthToDate.cost).toBe(7); // 3 + 4, May excluded
|
||||
expect(a.monthToDate.requests).toBe(3); // 2 + 1
|
||||
// last30d still includes the May record — proves MTD is a distinct window.
|
||||
expect(a.last30d.cost).toBe(12);
|
||||
});
|
||||
|
||||
it('returns zeroed monthToDate for no details', () => {
|
||||
const a = computeBarAnalytics([], NOW);
|
||||
expect(a.monthToDate).toEqual({ cost: 0, requests: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
function daily(over: Partial<DailyUsage>): DailyUsage {
|
||||
return {
|
||||
date: '2026-06-08',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
modelsUsed: [],
|
||||
modelBreakdowns: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function hourly(over: Partial<HourlyUsage>): HourlyUsage {
|
||||
return {
|
||||
hour: '2026-06-08 10:00',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
modelsUsed: [],
|
||||
modelBreakdowns: [],
|
||||
requestCount: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeBarAnalyticsFromDaily — monthToDate', () => {
|
||||
it('sums monthToDate cost (daily) and requests (hourly) for only the current calendar month', () => {
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[
|
||||
daily({ date: '2026-06-02', totalCost: 10 }), // June
|
||||
daily({ date: '2026-06-08', totalCost: 4 }), // June (today)
|
||||
daily({ date: '2026-05-25', totalCost: 7 }), // May, still within 30d
|
||||
],
|
||||
[
|
||||
hourly({ hour: '2026-06-02 10:00', requestCount: 5 }), // June
|
||||
hourly({ hour: '2026-06-08 09:00', requestCount: 3 }), // June
|
||||
hourly({ hour: '2026-05-25 10:00', requestCount: 99 }), // May
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.monthToDate.cost).toBe(14); // 10 + 4, May excluded
|
||||
expect(a.monthToDate.requests).toBe(8); // 5 + 3, May excluded
|
||||
// Distinct from last30d, which still carries the prior-month May record.
|
||||
expect(a.last30d.cost).toBe(21);
|
||||
});
|
||||
|
||||
it('resets monthToDate toward 0 on a fresh-month boundary while last30d stays populated', () => {
|
||||
// Treat the 1st of the month as "now": all activity sits in the prior month,
|
||||
// so MTD must be ~0 even though those days remain inside the rolling 30d.
|
||||
const firstOfMonth = new Date('2026-06-01T08:00:00-04:00');
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[daily({ date: '2026-05-20', totalCost: 12 }), daily({ date: '2026-05-31', totalCost: 8 })],
|
||||
[hourly({ hour: '2026-05-31 10:00', requestCount: 4 })],
|
||||
firstOfMonth
|
||||
);
|
||||
expect(a.monthToDate.cost).toBe(0);
|
||||
expect(a.monthToDate.requests).toBe(0);
|
||||
expect(a.last30d.cost).toBe(20); // rolling 30d still populated
|
||||
});
|
||||
|
||||
it('returns zeroed monthToDate for empty daily and hourly input', () => {
|
||||
const a = computeBarAnalyticsFromDaily([], [], NOW);
|
||||
expect(a.monthToDate).toEqual({ cost: 0, requests: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user