diff --git a/src/web-server/usage/bar-analytics.ts b/src/web-server/usage/bar-analytics.ts index f93015c3..746624e2 100644 --- a/src/web-server/usage/bar-analytics.ts +++ b/src/web-server/usage/bar-analytics.ts @@ -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(); 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(); 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, diff --git a/tests/unit/web-server/bar-analytics.test.ts b/tests/unit/web-server/bar-analytics.test.ts index 442f404a..ea53f367 100644 --- a/tests/unit/web-server/bar-analytics.test.ts +++ b/tests/unit/web-server/bar-analytics.test.ts @@ -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 { + 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 { + 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 }); + }); });