diff --git a/src/cliproxy/quota/quota-fetcher.ts b/src/cliproxy/quota/quota-fetcher.ts index 4a3699b7..bc6e92c5 100644 --- a/src/cliproxy/quota/quota-fetcher.ts +++ b/src/cliproxy/quota/quota-fetcher.ts @@ -835,11 +835,15 @@ export async function fetchAccountQuota( if (provider !== 'agy') { const error = `Quota not supported for provider: ${provider}`; if (verbose) console.error(`[!] Error: ${error}`); + // Stable machine code so callers branch on a code, not the human string. + // This is "no quota API for this provider", which is healthy — distinct + // from a transient fetch failure or an expired token. return { success: false, models: [], lastUpdated: Date.now(), error, + errorCode: 'quota_not_supported', }; } diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index bd9b00ad..82df9feb 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -21,7 +21,8 @@ import type { AccountInfo } from '../../cliproxy/accounts/types'; import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher'; import type { HealthReport } from '../health-service'; import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer'; -import { computeBarAnalytics } from '../usage/bar-analytics'; +import { computeBarAnalyticsFromDaily } from '../usage/bar-analytics'; +import type { DailyUsage, HourlyUsage } from '../usage/types'; // ============================================================================ // Types @@ -41,8 +42,21 @@ export interface BarSummaryRow { paused: boolean; /** Best-guess quota remaining percentage (0-100), null on error */ quota_percentage: number | null; + /** + * Tri-state quota availability for this account: + * 'ok' — provider has a quota API and the fetch succeeded + * 'unsupported' — provider has no quota API at all (e.g. ghcp, kiro) + * 'error' — provider should report quota but the fetch failed/timed out/needs reauth + * The UI uses this to render "no quota" (unsupported) vs "quota ?" (error) + * instead of a bare "--" that conflates the two. + */ + quotaStatus: 'ok' | 'unsupported' | 'error'; /** ISO timestamp of next quota reset, null if unknown */ next_reset: string | null; + /** Whether this is the provider's default account (drives the active/default badge) */ + is_default: boolean; + /** ISO timestamp this account was last used, null if never/unknown */ + last_activity_at: string | null; /** Today's attributed cost in USD, null if unavailable */ today_cost: number | null; /** Health status derived from overall system health */ @@ -77,6 +91,13 @@ export interface BarRouterDeps { getTodayCostByAccount: (details: CliproxyUsageHistoryDetail[]) => Record; /** Load persisted CLIProxy usage details (from snapshot cache) */ loadCliproxyDetails: () => Promise; + /** + * Load merged, multi-source daily usage (Claude Code, Codex, Droid, CLIProxy). + * Fresh, stale-while-revalidate; carries cost + per-model + per-surface spend. + */ + loadDailyUsage: () => Promise; + /** Load merged hourly usage — the source of request counts (daily lacks them). */ + loadHourlyUsage: () => Promise; /** * Optional, retained only for test back-compat. NOT used by the request path: * the bar derives per-account health from each quota result. The real system @@ -146,6 +167,25 @@ function withTimeout(p: Promise, ms: number): Promise { // Per-account health derivation // ============================================================================ +/** + * Tri-state quota availability derived from the QuotaResult. + * + * We branch ONLY on the result's stable errorCode, never on a provider + * registry: ghcp is listed in MANAGED_QUOTA_PROVIDERS, yet fetchAccountQuota + * returns unsupported for every provider !== 'agy'. The result's + * 'quota_not_supported' code is the only honest signal that a provider has no + * quota API, so we use it here. + * + * success → 'ok' + * errorCode 'quota_not_supported' → 'unsupported' (no quota API; healthy) + * anything else (null/timeout/reauth/other failure) → 'error' + */ +function deriveQuotaStatus(quota: QuotaResult | null): 'ok' | 'unsupported' | 'error' { + if (quota?.success === true) return 'ok'; + if (quota && quota.errorCode === 'quota_not_supported') return 'unsupported'; + return 'error'; +} + /** * Health for a single account, derived from its own quota-fetch result. * @@ -153,12 +193,21 @@ function withTimeout(p: Promise, ms: number): Promise { * system audit. (The system audit is also unsafe here: it shells out via a * synchronous execSync that would block the event loop on the request path.) * - * needsReauth → 'error' (token expired; user action required) - * fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal) - * success → 'ok' + * A provider with no quota API (quotaStatus 'unsupported', e.g. ghcp/kiro) is + * healthy — it must not show a permanent warning dot. 'warning' is reserved for + * genuine transient fetch failures, 'error' for accounts needing reauth. + * + * needsReauth → 'error' (token expired; user action required) + * quota unsupported → 'ok' (no quota API is not a fault) + * fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal) + * success → 'ok' */ -function deriveHealth(quota: QuotaResult | null): 'ok' | 'warning' | 'error' { +function deriveHealth( + quota: QuotaResult | null, + quotaStatus: 'ok' | 'unsupported' | 'error' +): 'ok' | 'warning' | 'error' { if (quota?.needsReauth) return 'error'; + if (quotaStatus === 'unsupported') return 'ok'; if (!quota || !quota.success) return 'warning'; return 'ok'; } @@ -267,12 +316,17 @@ function buildRow( ): BarSummaryRow { const { quota, cached, fetchedAt } = fetchResult; const costKey = resolveCostKey(account); - const health = deriveHealth(quota); + const quotaStatus = deriveQuotaStatus(quota); + const health = deriveHealth(quota, quotaStatus); + const isDefault = account.isDefault ?? false; + const lastActivityAt = account.lastUsedAt ?? null; - // Fix #11: when multiple accounts share the same cost-key (e.g. two codex accounts with - // the same email), we cannot attribute the combined cost to either individual account. - // Report null (unknowable) rather than the inflated shared total. - const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? 0); + // When multiple accounts share the same cost-key (e.g. two codex accounts with + // the same email), we cannot attribute the combined cost to either individual + // account, so it is null=unknowable. A missing key on a single-owner account is + // ALSO null=unknown (no usage record on a possibly-stale snapshot), distinct from + // a genuine 0 spend — the UI renders "no data" vs "$0.00" honestly. + const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? null); if (!quota || !quota.success) { // Degraded row: preserve identity fields, null out quota data @@ -283,7 +337,10 @@ function buildRow( tier: account.tier ?? null, paused: account.paused ?? false, quota_percentage: null, + quotaStatus, next_reset: null, + is_default: isDefault, + last_activity_at: lastActivityAt, today_cost: todayCost, health, cached, @@ -299,7 +356,10 @@ function buildRow( tier: quota.tier ?? account.tier ?? null, paused: account.paused ?? false, quota_percentage: extractQuotaPercentage(quota), + quotaStatus, next_reset: extractNextReset(quota), + is_default: isDefault, + last_activity_at: lastActivityAt, today_cost: todayCost, health, cached, @@ -425,14 +485,20 @@ export function createBarRouter(deps: BarRouterDeps): Router { /** * GET /analytics * - * Rolls up the persisted usage snapshot into today / 7-day / 30-day spend, a - * 7-day sparkline, and the top models. Read-only and cheap (the snapshot is - * already on disk); bounded so a slow read can't stall the menu. + * Rolls up the merged, multi-source usage (Claude Code, Codex, Droid, CLIProxy) + * into today / 7-day / 30-day spend, a 30-day sparkline, top models, and a + * per-surface breakdown. Reads the dashboard's stale-while-revalidate caches so + * recent activity shows even when the CLIProxy snapshot is frozen by a restart. + * Both loads are bounded so a slow read can't stall the menu; on miss the + * windows degrade to empty rather than failing the payload. */ router.get('/analytics', async (_req: Request, res: Response): Promise => { try { - const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS); - const analytics = computeBarAnalytics(details ?? [], new Date()); + const [daily, hourly] = await Promise.all([ + withTimeout(deps.loadDailyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as DailyUsage[]), + withTimeout(deps.loadHourlyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as HourlyUsage[]), + ]); + const analytics = computeBarAnalyticsFromDaily(daily ?? [], hourly ?? [], new Date()); res.json(analytics); } catch (err) { console.error('[bar-routes] /analytics error:', (err as Error).message); @@ -456,6 +522,7 @@ import { import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher'; import { getTodayCostByAccount } from '../usage/data-aggregator'; import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader'; +import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator'; /** Production bar router — wired to real dependencies */ const barRouter: Router = createBarRouter({ @@ -466,6 +533,8 @@ const barRouter: Router = createBarRouter({ fetchAccountQuota, getTodayCostByAccount, loadCliproxyDetails: loadCliproxySnapshotDetails, + loadDailyUsage: () => getCachedDailyData(), + loadHourlyUsage: () => getCachedHourlyData(), }); export default barRouter; diff --git a/src/web-server/usage/bar-analytics.ts b/src/web-server/usage/bar-analytics.ts index 80186906..f93015c3 100644 --- a/src/web-server/usage/bar-analytics.ts +++ b/src/web-server/usage/bar-analytics.ts @@ -11,6 +11,7 @@ */ import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer'; +import type { DailyUsage, HourlyUsage } from './types'; /** A single day's roll-up (local-day granularity). */ export interface BarAnalyticsDay { @@ -20,6 +21,21 @@ export interface BarAnalyticsDay { requests: number; } +/** + * One usage surface's contribution to spend over the active window. + * A "surface" is the tool/origin a request came from (Claude Code, Codex, the + * CLIProxy router, Droid, …) — the dimension the menu bar uses to answer + * "where is my usage actually going". + */ +export interface BarAnalyticsSurface { + /** Raw pipeline source key (custom-parser | codex-native | cliproxy | droid-native | …). */ + source: string; + /** Human label shown in the bar (Claude Code, Codex, CLIProxy, Droid, …). */ + surface: string; + cost: number; + requests: number; +} + /** Aggregate spend over a rolling window. */ export interface BarAnalyticsWindow { cost: number; @@ -40,17 +56,34 @@ export interface BarAnalytics { last30d: BarAnalyticsWindow; /** Lifetime totals across every record in the snapshot. */ allTime: BarAnalyticsWindow; - /** Oldest → newest, exactly 7 entries (zero-filled), for the sparkline. */ + /** Oldest → newest, exactly 30 entries (zero-filled), for the sparkline. */ byDay: BarAnalyticsDay[]; /** Highest-spend models (descending, capped) for the window in `topModelsWindow`. */ topModels: BarAnalyticsModel[]; /** Which window `topModels` covers — the most recent one that has data. */ topModelsWindow: '30d' | 'all'; + /** + * Spend/requests broken down by usage surface (tool/origin), for the same + * window as `topModels`. Descending by cost. Empty when no source is known + * (e.g. the legacy snapshot-only path that carries no surface dimension). + */ + bySurface: BarAnalyticsSurface[]; + /** ISO timestamp of the most recent non-failed usage record, null if none. */ + lastActivityAt: string | null; + /** Whole local-days since `lastActivityAt`, null if no usable records. */ + daysSinceLastActivity: number | null; + /** + * True when the trailing 30 days carry any spend or requests. The UI pivots + * its empty/stale presentation on this without re-deriving it. + */ + hasRecentData: boolean; /** ISO timestamp the payload was generated. */ generatedAt: string; } -const SPARKLINE_DAYS = 7; +// 30-day trailing window: gives a non-empty sparkline shape even when the last +// 7 days are zero, so a stale-but-real history doesn't read as a broken chart. +const SPARKLINE_DAYS = 30; const TOP_MODELS_LIMIT = 5; /** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */ @@ -108,6 +141,10 @@ export function computeBarAnalytics( } }; + // Epoch ms of the most recent non-failed record, tracked inside the single + // loop the function already runs (O(1) extra per iteration, no new I/O). + let lastActivityMs = -Infinity; + for (const detail of details) { if (detail.failed) continue; const ts = new Date(detail.timestamp); @@ -116,6 +153,8 @@ export function computeBarAnalytics( const delta = dayDelta(now, ts); // 0 = today, 1 = yesterday, … if (delta < 0) continue; // ignore future-dated noise + if (ts.getTime() > lastActivityMs) lastActivityMs = ts.getTime(); + const cost = Number.isFinite(detail.cost) ? detail.cost : 0; const requests = Number.isFinite(detail.requestCount) ? detail.requestCount : 0; @@ -127,9 +166,12 @@ export function computeBarAnalytics( today.cost += cost; today.requests += requests; } + // Window math stays 7-day; only the sparkline bucket fill widens to 30. if (delta < 7) { last7d.cost += cost; last7d.requests += requests; + } + if (delta < SPARKLINE_DAYS) { const bucket = dayBuckets.get(localDayKey(ts)); if (bucket) { bucket.cost += cost; @@ -143,6 +185,11 @@ export function computeBarAnalytics( } } + const lastActivityAt = + lastActivityMs === -Infinity ? null : new Date(lastActivityMs).toISOString(); + const daysSinceLastActivity = + lastActivityAt === null ? null : dayDelta(now, new Date(lastActivityAt)); + // Prefer recent leaders; fall back to lifetime when the last 30 days are idle. const recentHasData = last30d.cost > 0 || last30d.requests > 0; const sourceMap = recentHasData ? model30d : modelAll; @@ -159,6 +206,177 @@ export function computeBarAnalytics( byDay: Array.from(dayBuckets.values()), topModels, topModelsWindow: recentHasData ? '30d' : 'all', + // The snapshot-detail path has no surface attribution; the daily path does. + bySurface: [], + lastActivityAt, + daysSinceLastActivity, + hasRecentData: recentHasData, + generatedAt: now.toISOString(), + }; +} + +// Maps a raw usage-pipeline `source` to the label shown in the bar. Unknown +// sources fall through to their raw key so a new collector is never silently +// dropped — it just shows un-prettified until added here. +const SURFACE_LABELS: Record = { + 'custom-parser': 'Claude Code', + 'codex-native': 'Codex', + 'droid-native': 'Droid', + cliproxy: 'CLIProxy', +}; + +/** Human-friendly surface name for a raw usage `source` key. */ +function surfaceLabel(source: string): string { + if (SURFACE_LABELS[source]) return SURFACE_LABELS[source]; + return source || 'Other'; +} + +/** Local-midnight Date from a YYYY-MM-DD day key (calendar-day anchored). */ +function dateFromDayKey(key: string): Date { + const [y, m, d] = key.split('-').map((n) => parseInt(n, 10)); + return new Date(y, (m || 1) - 1, d || 1); +} + +/** + * Roll the merged, multi-source usage aggregates into the bar analytics payload. + * + * Unlike `computeBarAnalytics` (which reads only the CLIProxy snapshot — frozen + * whenever the proxy restarts), this consumes the same merged daily/hourly data + * the dashboard uses, so recent activity from Claude Code, Codex, Droid, and the + * CLIProxy router all show up. `daily` carries cost+models+source; `hourly` + * carries the request counts (daily aggregates don't), so the two are combined: + * cost/models/surface-cost from daily, request counts from hourly. + */ +export function computeBarAnalyticsFromDaily( + daily: DailyUsage[], + hourly: HourlyUsage[], + now: Date +): BarAnalytics { + const today: BarAnalyticsWindow = { cost: 0, requests: 0 }; + const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 }; + const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 }; + const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 }; + + const dayBuckets = new Map(); + for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i); + dayBuckets.set(localDayKey(d), { date: localDayKey(d), cost: 0, requests: 0 }); + } + + const model30d = new Map(); + const modelAll = new Map(); + const bumpModel = (map: Map, model: string, cost: number): void => { + const existing = map.get(model); + if (existing) existing.cost += cost; + else map.set(model, { model, cost, requests: 0 }); + }; + + const surface30d = new Map(); + const surfaceAll = new Map(); + const bumpSurface = ( + map: Map, + source: string, + cost: number, + requests: number + ): void => { + const existing = map.get(source); + if (existing) { + existing.cost += cost; + existing.requests += requests; + } else { + map.set(source, { source, surface: surfaceLabel(source), cost, requests }); + } + }; + + // Latest local-day with real activity (cost or requests), across both passes. + let lastActivityKey: string | null = null; + const touchActivity = (dayKey: string): void => { + if (lastActivityKey === null || dayKey > lastActivityKey) lastActivityKey = dayKey; + }; + + // Pass 1 — daily: cost, per-model spend, per-surface spend, sparkline cost. + for (const d of daily) { + if (!d || !d.date) continue; + const delta = dayDelta(now, dateFromDayKey(d.date)); + if (delta < 0) continue; // ignore future-dated noise + const cost = Number.isFinite(d.totalCost) ? d.totalCost : Number.isFinite(d.cost) ? d.cost : 0; + const source = d.source || ''; + + allTime.cost += cost; + bumpSurface(surfaceAll, source, cost, 0); + for (const mb of d.modelBreakdowns || []) { + bumpModel(modelAll, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0); + } + if (cost > 0) touchActivity(d.date); + + if (delta === 0) today.cost += cost; + if (delta < 7) last7d.cost += cost; + if (delta < 30) { + last30d.cost += cost; + bumpSurface(surface30d, source, cost, 0); + for (const mb of d.modelBreakdowns || []) { + bumpModel(model30d, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0); + } + } + if (delta < SPARKLINE_DAYS) { + const bucket = dayBuckets.get(d.date); + if (bucket) bucket.cost += cost; + } + } + + // Pass 2 — hourly: request counts (daily aggregates don't carry them). + for (const h of hourly) { + if (!h || !h.hour) continue; + const dayKey = h.hour.slice(0, 10); + const delta = dayDelta(now, dateFromDayKey(dayKey)); + if (delta < 0) continue; + const requests = Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0; + if (requests <= 0) continue; + const source = h.source || ''; + + allTime.requests += requests; + bumpSurface(surfaceAll, source, 0, requests); + touchActivity(dayKey); + + if (delta === 0) today.requests += requests; + if (delta < 7) last7d.requests += requests; + if (delta < 30) { + last30d.requests += requests; + bumpSurface(surface30d, source, 0, requests); + } + if (delta < SPARKLINE_DAYS) { + const bucket = dayBuckets.get(dayKey); + if (bucket) bucket.requests += requests; + } + } + + // Prefer recent leaders; fall back to lifetime when the last 30 days are idle. + const recentHasData = last30d.cost > 0 || last30d.requests > 0; + const topModels = Array.from((recentHasData ? model30d : modelAll).values()) + .filter((m) => m.cost > 0 || m.requests > 0) + .sort((a, b) => b.cost - a.cost) + .slice(0, TOP_MODELS_LIMIT); + const bySurface = Array.from((recentHasData ? surface30d : surfaceAll).values()) + .filter((s) => s.cost > 0 || s.requests > 0) + .sort((a, b) => b.cost - a.cost); + + const lastActivityAt = lastActivityKey ? dateFromDayKey(lastActivityKey).toISOString() : null; + const daysSinceLastActivity = lastActivityKey + ? dayDelta(now, dateFromDayKey(lastActivityKey)) + : null; + + return { + today, + last7d, + last30d, + allTime, + byDay: Array.from(dayBuckets.values()), + topModels, + topModelsWindow: recentHasData ? '30d' : 'all', + bySurface, + lastActivityAt, + daysSinceLastActivity, + hasRecentData: recentHasData, generatedAt: now.toISOString(), }; } diff --git a/tests/unit/web-server/bar-analytics.test.ts b/tests/unit/web-server/bar-analytics.test.ts index daedbcae..442f404a 100644 --- a/tests/unit/web-server/bar-analytics.test.ts +++ b/tests/unit/web-server/bar-analytics.test.ts @@ -29,9 +29,13 @@ describe('computeBarAnalytics', () => { const a = computeBarAnalytics([], NOW); expect(a.today.cost).toBe(0); expect(a.allTime.cost).toBe(0); - expect(a.byDay).toHaveLength(7); + expect(a.byDay).toHaveLength(30); expect(a.topModels).toHaveLength(0); expect(a.topModelsWindow).toBe('all'); + // No usable records → no last-activity signal, not stale-but-present. + expect(a.lastActivityAt).toBeNull(); + expect(a.daysSinceLastActivity).toBeNull(); + expect(a.hasRecentData).toBe(false); }); it('rolls today / 7d / 30d / allTime into the right windows', () => { @@ -60,15 +64,48 @@ describe('computeBarAnalytics', () => { expect(a.allTime.cost).toBe(1); }); - it('zero-fills the 7-day sparkline in chronological order', () => { + it('zero-fills the 30-day sparkline in chronological order', () => { const a = computeBarAnalytics([detail({ timestamp: daysAgo(2), cost: 4 })], NOW); - expect(a.byDay).toHaveLength(7); + expect(a.byDay).toHaveLength(30); // oldest first, newest last - expect(a.byDay[0].date < a.byDay[6].date).toBe(true); + expect(a.byDay[0].date < a.byDay[29].date).toBe(true); const hit = a.byDay.find((d) => d.cost > 0); expect(hit?.cost).toBe(4); }); + it('populates sparkline days 8..30 from records older than the 7-day window', () => { + // A record 20 days ago is outside last7d but inside the 30-day sparkline: + // the bucket must fill so the chart isn't flat when only old data exists. + const a = computeBarAnalytics([detail({ timestamp: daysAgo(20), cost: 6 })], NOW); + expect(a.last7d.cost).toBe(0); // 7-day window math unchanged + const hit = a.byDay.find((d) => d.cost > 0); + expect(hit?.cost).toBe(6); + }); + + it('reports last-activity and hasRecentData from the freshest non-failed record', () => { + const recent = daysAgo(1); + const a = computeBarAnalytics( + [ + detail({ timestamp: daysAgo(5), cost: 1 }), + detail({ timestamp: recent, cost: 2 }), + // failed record must NOT count as activity even though it's newer + detail({ timestamp: daysAgo(0), cost: 9, failed: true }), + ], + NOW + ); + expect(a.lastActivityAt).toBe(recent); + expect(a.daysSinceLastActivity).toBe(1); + expect(a.hasRecentData).toBe(true); + }); + + it('reports hasRecentData false and last-activity from old data when the 30-day window is idle', () => { + const old = daysAgo(45); + const a = computeBarAnalytics([detail({ timestamp: old, cost: 5 })], NOW); + expect(a.hasRecentData).toBe(false); + expect(a.lastActivityAt).toBe(old); + expect(a.daysSinceLastActivity).toBe(45); + }); + it('ranks top models by spend and labels the window 30d when recent data exists', () => { const a = computeBarAnalytics( [ diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 845510e2..4629b951 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -21,7 +21,10 @@ interface BarSummaryRow { tier: string | null; paused: boolean; quota_percentage: number | null; + quotaStatus: 'ok' | 'unsupported' | 'error'; next_reset: string | null; + is_default: boolean; + last_activity_at: string | null; today_cost: number | null; health: 'ok' | 'warning' | 'error'; cached: boolean; @@ -71,6 +74,7 @@ function makeQuotaResult( models: Array<{ name: string; percentage: number; resetTime: string | null }>; needsReauth: boolean; error: string; + errorCode: string; lastUpdated: number; }> = {} ) { @@ -82,6 +86,7 @@ function makeQuotaResult( lastUpdated: overrides.lastUpdated ?? Date.now(), needsReauth: overrides.needsReauth ?? false, ...(overrides.error !== undefined && { error: overrides.error }), + ...(overrides.errorCode !== undefined && { errorCode: overrides.errorCode }), }; } @@ -166,6 +171,8 @@ describe('GET /api/bar/summary', () => { }, getTodayCostByAccount: (_details: unknown[]) => mockCostByAccount, loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], runHealthChecks: async () => mockHealthReport, }); @@ -221,6 +228,10 @@ describe('GET /api/bar/summary', () => { expect(typeof row.health).toBe('string'); expect(['ok', 'warning', 'error']).toContain(row.health); expect(typeof row.needsReauth).toBe('boolean'); + // New contract fields + expect(['ok', 'unsupported', 'error']).toContain(row.quotaStatus); + expect(typeof row.is_default).toBe('boolean'); + expect(row.last_activity_at === null || typeof row.last_activity_at === 'string').toBe(true); }); it('maps account metadata into the row correctly', async () => { @@ -246,6 +257,10 @@ describe('GET /api/bar/summary', () => { expect(row.paused).toBe(false); expect(row.quota_percentage).toBe(60); expect(row.next_reset).toBe('2026-06-08T00:00:00Z'); + // is_default mirrors account.isDefault (factory default true); successful + // quota fetch → quotaStatus 'ok'. + expect(row.is_default).toBe(true); + expect(row.quotaStatus).toBe('ok'); }); // -------------------------------------------------------------------------- @@ -388,15 +403,16 @@ describe('GET /api/bar/summary', () => { expect(row.today_cost).toBeCloseTo(1.23); }); - it('today_cost is 0 or null for accounts with no cost record', async () => { + it('today_cost is null (unknown) for accounts with no cost record', async () => { mockAccounts = [makeAccountInfo({ id: 'nocost@example.com', provider: 'agy' })]; mockCostByAccount = {}; // no entry for this account const { body } = await getJson(baseUrl, '/api/bar/summary'); const row = body[0]; - // Should be 0 or null — either is acceptable, not a hard error - expect(row.today_cost === 0 || row.today_cost === null).toBe(true); + // A missing cost-key means "no usage data" (possibly stale snapshot), which is + // null=unknown — distinct from a genuine 0 spend. The UI renders "no data". + expect(row.today_cost).toBeNull(); }); // -------------------------------------------------------------------------- @@ -435,6 +451,61 @@ describe('GET /api/bar/summary', () => { expect(body[0].health).toBe('error'); }); + // -------------------------------------------------------------------------- + // quotaStatus tri-state (unsupported vs error vs ok) and its health mapping + // -------------------------------------------------------------------------- + + it('quotaStatus is "ok" and health "ok" on a successful quota fetch', async () => { + mockQuotaResult = makeQuotaResult({ success: true }); + + const { body } = await getJson(baseUrl, '/api/bar/summary'); + expect(body[0].quotaStatus).toBe('ok'); + expect(body[0].health).toBe('ok'); + }); + + it('provider without a quota API (errorCode quota_not_supported) → quotaStatus "unsupported" and health "ok"', async () => { + // Mirrors fetchAccountQuota for any provider !== "agy" (e.g. ghcp/kiro): + // success false with the stable quota_not_supported code. This must read as + // healthy (no permanent orange dot), not a transient warning. + mockQuotaResult = makeQuotaResult({ + success: false, + models: [], + error: 'Quota not supported for provider: ghcp', + errorCode: 'quota_not_supported', + }); + + const { body } = await getJson(baseUrl, '/api/bar/summary'); + expect(body[0].quotaStatus).toBe('unsupported'); + expect(body[0].health).toBe('ok'); + expect(body[0].quota_percentage).toBeNull(); + }); + + it('transient fetch failure (no errorCode, no reauth) → quotaStatus "error" and health "warning"', async () => { + mockQuotaResult = makeQuotaResult({ + success: false, + needsReauth: false, + models: [], + error: 'temporary network blip', + }); + + const { body } = await getJson(baseUrl, '/api/bar/summary'); + expect(body[0].quotaStatus).toBe('error'); + expect(body[0].health).toBe('warning'); + }); + + it('needsReauth → quotaStatus "error" and health "error"', async () => { + mockQuotaResult = makeQuotaResult({ + success: false, + needsReauth: true, + models: [], + error: 'token expired', + }); + + const { body } = await getJson(baseUrl, '/api/bar/summary'); + expect(body[0].quotaStatus).toBe('error'); + expect(body[0].health).toBe('error'); + }); + // -------------------------------------------------------------------------- // displayName // -------------------------------------------------------------------------- @@ -526,6 +597,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', () fetchAccountQuota: async () => makeQuotaResult(), getTodayCostByAccount: () => costMap, loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], runHealthChecks: async () => makeHealthReport(), }); @@ -553,7 +626,7 @@ describe('today_cost key consistency — non-email account.id (finding #4)', () expect(row.today_cost).toBeCloseTo(2.5); }); - it('attributes cost correctly for account with no email (kiro/ghcp type): cost remains 0', async () => { + it('attributes cost correctly for account with no email (kiro/ghcp type): cost is null (no record)', async () => { const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes'); const { resetForceFreshDebounce: resetDebounce } = await import( '../../../src/web-server/routes/bar-routes' @@ -591,6 +664,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', () fetchAccountQuota: async () => makeQuotaResult(), getTodayCostByAccount: () => costMap2, loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], runHealthChecks: async () => makeHealthReport(), }); @@ -610,7 +685,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', () const { body } = await getJson(baseUrl2, '/api/bar/summary'); expect(body.length).toBe(1); - expect(body[0].today_cost).toBe(0); + // No matching cost-key → null=unknown (not a misleading $0.00). + expect(body[0].today_cost).toBeNull(); await new Promise((resolve) => server2.close(() => resolve())); }); @@ -659,6 +735,8 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => { }, getTodayCostByAccount: () => ({}), loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], runHealthChecks: async () => makeHealthReport(), }); @@ -746,6 +824,8 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () => }, getTodayCostByAccount: () => ({}), loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], runHealthChecks: async () => makeHealthReport(), }); @@ -817,6 +897,8 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => { fetchAccountQuota: async () => makeQuotaResult(), getTodayCostByAccount: () => costMap, loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], runHealthChecks: async () => makeHealthReport(), });