From 5f850c4899a3d7f64385a72a4d1d5080206a1023 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 9 Jun 2026 11:49:36 -0400 Subject: [PATCH 1/2] feat(bar): honest quota state and merged multi-source analytics Derive a tri-state quotaStatus (ok|unsupported|error) per account so a provider with no quota API renders as 'no quota' with a healthy dot instead of an alarming bare dash, and treat unsupported as healthy in deriveHealth. Switch the analytics endpoint off the CLIProxy snapshot, which freezes whenever the proxy restarts (usage is in-memory only), and onto the merged daily/hourly usage the dashboard uses. Recent activity from Claude Code, Codex, and Droid now shows, and a per-surface breakdown answers where usage goes. Add lastActivityAt, daysSinceLastActivity, hasRecentData and a 30-day series; per-account today_cost reads null (unknown) rather than a misleading 0. --- src/cliproxy/quota/quota-fetcher.ts | 4 + src/web-server/routes/bar-routes.ts | 99 +++++++-- src/web-server/usage/bar-analytics.ts | 222 +++++++++++++++++++- tests/unit/web-server/bar-analytics.test.ts | 45 +++- tests/unit/web-server/bar-routes.test.ts | 92 +++++++- 5 files changed, 436 insertions(+), 26 deletions(-) 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(), }); From 4a633eac65adefab4d04a265d0993172d9fca001 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 9 Jun 2026 11:49:55 -0400 Subject: [PATCH 2/2] feat(bar-app): per-surface usage UI, unambiguous title, hidden scrollbar Mirror the richer analytics payload (quotaStatus, per-account last-active, bySurface) in the Codable models. Add a per-surface usage section (Claude Code, Codex, Droid, CLIProxy) with proportional bars beside the existing spend grid, sparkline, and top models. Rebuild the menu-bar title chain so a lifetime dollar can never sit in the always-on title (it read as live spend): quota% then today spend then account count. Rebuild the accounts rows (default badge, tri-state quota, honest no-data cost, health), pivot to an honest idle hero when no recent data, hide the scroll indicator, and tighten spacing. --- .../Sources/CCSBarApp/BarAnalyticsView.swift | 156 ++++++++++++-- macos-bar/Sources/CCSBarApp/BarMenuView.swift | 149 +++++++++++--- .../Sources/CCSBarApp/BarViewModel.swift | 2 +- macos-bar/Sources/CCSBarCheck/main.swift | 194 +++++++++++++++--- .../Sources/CCSBarCore/BarAnalytics.swift | 60 +++++- .../Sources/CCSBarCore/BarFormatting.swift | 121 ++++++++--- macos-bar/Sources/CCSBarCore/BarSummary.swift | 19 ++ 7 files changed, 595 insertions(+), 106 deletions(-) diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift index ae45daf8..028f5060 100644 --- a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -1,35 +1,41 @@ import SwiftUI import CCSBarCore -/// Usage analytics block: spend rollups, a 7-day cost sparkline, and top models. +/// Usage analytics block: spend rollups, a 30-day cost sparkline, surface +/// breakdown, and top models. +/// +/// Pivots on `hasRecentData`: when the trailing 30 days carry no spend, the three +/// dead Today/7d/30d cells read as "broken", so they collapse into one honest +/// "No usage in N days" line with all-time + last-active promoted to the hero. struct BarAnalyticsView: View { let analytics: BarAnalytics + private var lastActive: String? { + BarFormatting.lastActiveLabel( + iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity) + } + var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 8) { SectionLabel("Usage") - // Spend rollups, 2 x 2. - VStack(spacing: 6) { - HStack(spacing: 6) { - StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost)) - StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost)) - } - HStack(spacing: 6) { - StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost)) - StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true) - } + if analytics.hasRecentData { + recentGrid + } else { + idleHero } - // 7-day sparkline. - VStack(alignment: .leading, spacing: 5) { - HStack { - Text("Last 7 days").font(.caption2).foregroundStyle(.secondary) - Spacer() - Text("\(BarFormatting.count(analytics.last7d.requests)) req") - .font(.caption2).foregroundStyle(.secondary) + sparklineBlock + + // Surface breakdown: "how much Claude Code vs Codex" — only shown when + // the backend supplies at least one surface entry. Top 5 keeps the section + // compact; the full list is available in the dashboard. + if !analytics.bySurface.isEmpty { + SectionLabel("By surface") + let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1 + ForEach(analytics.bySurface.prefix(5)) { surface in + SurfaceBar(surface: surface, peak: peakSurface) } - Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 30) } // Top models. @@ -43,6 +49,112 @@ struct BarAnalyticsView: View { } } } + + /// Live spend grid (only when recent windows actually carry data). + private var recentGrid: some View { + VStack(spacing: 5) { + HStack(spacing: 5) { + StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost)) + StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost)) + } + HStack(spacing: 5) { + StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost)) + StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true) + } + } + } + + /// Honest idle state: a single "No usage in N days" line, with all-time spend + /// promoted to the hero and the last-active caption underneath. + private var idleHero: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Image(systemName: "moon.zzz") + .font(.caption) + .foregroundStyle(.secondary) + Text(idleHeadline) + .font(.caption) + .foregroundStyle(.secondary) + } + StatCell( + title: "All-time spend", + value: BarFormatting.money(analytics.allTime.cost), + accent: true) + if let lastActive { + Text(lastActive) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + private var idleHeadline: String { + if let d = analytics.daysSinceLastActivity { + return "No usage in \(d) days" + } + return "No usage in 30 days" + } + + /// Sparkline over the 30-day series, with an honest placeholder when every day + /// is zero (a flat line with no context reads as broken). + private var sparklineBlock: some View { + VStack(alignment: .leading, spacing: 4) { + if sparklineIsEmpty { + Text(idleHeadline) + .font(.caption2).foregroundStyle(.secondary) + if let lastActive { + Text(lastActive).font(.caption2).foregroundStyle(.tertiary) + } + } else { + HStack { + Text("Last 30 days").font(.caption2).foregroundStyle(.secondary) + Spacer() + Text("\(BarFormatting.count(analytics.last30d.requests)) req") + .font(.caption2).foregroundStyle(.secondary) + } + Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 28) + } + } + } + + private var sparklineIsEmpty: Bool { + analytics.byDay.allSatisfy { $0.cost <= 0 } + } +} + +/// One usage-surface row: surface name + proportional accent bar + cost and +/// request count. Mirrors ModelBar visually so the two sections feel cohesive. +private struct SurfaceBar: View { + let surface: BarAnalyticsSurface + let peak: Double + + var body: some View { + GeometryReader { geo in + let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0 + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 5) + .fill(BarTheme.accent.opacity(0.16)) + .frame(width: max(8, geo.size.width * fraction)) + HStack { + Text(surface.surface) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + HStack(spacing: 4) { + Text(BarFormatting.count(surface.requests)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + Text(BarFormatting.money(surface.cost)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 8) + } + } + .frame(height: 22) + } } /// A small labelled stat tile. @@ -63,7 +175,7 @@ private struct StatCell: View { .minimumScaleFactor(0.7) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) + .padding(.vertical, 5) .padding(.horizontal, 9) .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7)) } @@ -106,6 +218,6 @@ struct SectionLabel: View { Text(text.uppercased()) .font(.system(size: 10, weight: .bold)) .foregroundStyle(.secondary) - .padding(.top, 2) + .padding(.top, 1) } } diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index d9cdb32e..9b4bac8e 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -16,14 +16,20 @@ struct BarMenuView: View { if viewModel.offline { offlineState.padding(14) } else { + // Scrollbar is hidden: the panel chrome already constrains height and a + // visible scrollbar track adds visual clutter in an always-on-screen widget. + // Overflow is still fully scrollable — the indicator is just not shown. ScrollView { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { if let analytics = viewModel.analytics { BarAnalyticsView(analytics: analytics) } - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { SectionLabel("Accounts") + if let error = viewModel.lastError { + ErrorBanner(message: error) + } if viewModel.rows.isEmpty { Text("No accounts configured") .font(.caption) @@ -35,9 +41,13 @@ struct BarMenuView: View { } } } - .padding(14) + .padding(12) } - .frame(maxHeight: 520) + .scrollIndicators(.hidden) + // 580 gives room for the full layout (2×2 grid + sparkline + surface section + // + top models + accounts) without wasted whitespace on a typical 1-4 account + // setup. The scroll still triggers gracefully when content overflows. + .frame(maxHeight: 580) } Divider() @@ -118,17 +128,23 @@ struct BarMenuView: View { } } -/// One account row: colored health dot, name, a chip subline, today's cost, and -/// a control menu. +/// One account row — the strongest section of the glance. +/// +/// Top line: health dot, name, default/paused/reauth badges. Subline: provider + +/// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"), +/// and a per-account "Last active " caption. Trailing: today's cost (or a +/// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume +/// toggle, and the overflow menu (set-default / solo / tier-lock). struct BarRowView: View { let row: BarSummaryRow @ObservedObject var viewModel: BarViewModel var body: some View { - HStack(alignment: .center, spacing: 9) { + HStack(alignment: .top, spacing: 9) { Circle() .fill(healthColor) .frame(width: 8, height: 8) + .padding(.top, 5) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { @@ -136,6 +152,9 @@ struct BarRowView: View { .font(.system(.body, design: .default).weight(.medium)) .lineLimit(1) .truncationMode(.middle) + if row.isDefault { + Chip("default", tint: BarTheme.accent) + } if row.paused { Chip("paused", tint: .secondary) } @@ -146,46 +165,79 @@ struct BarRowView: View { HStack(spacing: 6) { Chip(row.provider, tint: BarTheme.accent) if let tier = row.tier { Chip(tier, tint: .secondary) } - Text(BarFormatting.quotaLabel(row.quotaPercentage)) + Text(BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus)) + .font(.caption2) + .foregroundStyle(quotaColor) + } + if let lastActive = BarFormatting.lastActiveLabel( + iso: row.lastActivityAt, daysSince: nil) + { + Text(lastActive) .font(.caption2) .foregroundStyle(.secondary) } } - Spacer() + Spacer(minLength: 4) - let cost = BarFormatting.costLabel(row.todayCost) - if !cost.isEmpty { - Text(cost) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - - Menu { - if row.paused { - Button("Resume") { viewModel.resume(row) } - } else { - Button("Pause") { viewModel.pause(row) } + VStack(alignment: .trailing, spacing: 3) { + costView + HStack(spacing: 2) { + pauseToggle + overflowMenu } - Button("Set as default") { viewModel.setDefault(row) } - Button("Solo (pause others)") { viewModel.solo(row) } - Divider() - if let tier = row.tier { - Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) } - } - Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) } - } label: { - Image(systemName: "ellipsis.circle") } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .frame(width: 28) } - .padding(.vertical, 5) + .padding(.vertical, 6) .padding(.horizontal, 8) .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) } + /// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted + /// "no data" when the value is null (no usage record on a possibly-stale snapshot). + @ViewBuilder private var costView: some View { + if let cost = row.todayCost { + Text(BarFormatting.money(cost)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else { + Text("no data") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + /// Visible primary action: one tap to pause or resume the account. + private var pauseToggle: some View { + Button { + if row.paused { viewModel.resume(row) } else { viewModel.pause(row) } + } label: { + Image(systemName: row.paused ? "play.circle" : "pause.circle") + } + .buttonStyle(.borderless) + .help(row.paused ? "Resume account" : "Pause account") + } + + private var overflowMenu: some View { + Menu { + Button("Set as default") { viewModel.setDefault(row) } + Button("Solo (pause others)") { viewModel.solo(row) } + Divider() + if let tier = row.tier { + Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) } + } + Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(width: 24) + } + + /// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro) + /// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved + /// for genuine transient fetch failures, red for accounts needing reauth. private var healthColor: Color { switch row.health { case "error": return .red @@ -193,6 +245,35 @@ struct BarRowView: View { default: return .green } } + + /// Quota label color: muted for "no quota", warning-tinted for "quota ?". + private var quotaColor: Color { + switch row.quotaStatus { + case "unsupported": return .secondary + case "error": return .orange + default: return .secondary + } + } +} + +/// Inline banner surfacing the last failed action so it is visible rather than +/// silently swallowed. Success is confirmed by the default/paused badge updating. +struct ErrorBanner: View { + let message: String + var body: some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(message) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 5) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + } } /// Small pill label used in account sublines. diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index 4f46353a..72132f98 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -33,7 +33,7 @@ final class BarViewModel: ObservableObject { /// Compact status-bar title. var statusTitle: String { - offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows) + offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows, analytics: analytics) } /// Resolve the discovery file and (re)build the client. Marks offline when diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 7c619536..ceafb2b8 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -42,7 +42,10 @@ let summaryJSON = """ "tier": "ultra", "paused": false, "quota_percentage": 82.4, + "quotaStatus": "ok", "next_reset": "2026-06-08T00:00:00Z", + "is_default": true, + "last_activity_at": "2026-04-29T10:00:00Z", "today_cost": 3.2, "health": "ok", "cached": true, @@ -56,9 +59,12 @@ let summaryJSON = """ "tier": null, "paused": true, "quota_percentage": null, + "quotaStatus": "error", "next_reset": null, + "is_default": false, + "last_activity_at": null, "today_cost": null, - "health": "warning", + "health": "error", "cached": false, "fetchedAt": null, "needsReauth": true @@ -71,15 +77,35 @@ do { check(rows.count == 2, "decodes two rows") check(rows[0].accountId == "alice@example.com", "maps account_id") check(rows[0].quotaPercentage == 82.4, "maps quota_percentage") + check(rows[0].quotaStatus == "ok", "maps quotaStatus (ok)") + check(rows[0].isDefault == true, "maps is_default") + check(rows[0].lastActivityAt == "2026-04-29T10:00:00Z", "maps last_activity_at") check(rows[0].todayCost == 3.2, "maps today_cost") check(rows[0].id == "agy:alice@example.com", "stable id is provider:account") check(rows[1].quotaPercentage == nil, "null quota decodes to nil") + check(rows[1].quotaStatus == "error", "maps quotaStatus (error)") + check(rows[1].isDefault == false, "is_default false decodes") + check(rows[1].lastActivityAt == nil, "null last_activity_at decodes to nil") check(rows[1].paused == true, "maps paused") check(rows[1].needsReauth == true, "maps needsReauth") - check(rows[1].healthDot == "!", "warning -> ! dot") + check(rows[1].healthDot == "X", "error -> X dot") check(rows[0].healthDot == "OK", "ok -> OK dot") -} catch { - check(false, "decoding threw: \(error)") +} + +// An "unsupported" provider (ghcp/kiro) decodes with quotaStatus "unsupported" +// and health "ok" — the backend's key correctness fix (no permanent orange dot). +do { + let unsupportedJSON = """ + [{ + "account_id": "copilot-kaitranntt", "provider": "ghcp", "displayName": "copilot-kaitranntt", + "tier": null, "paused": false, "quota_percentage": null, "quotaStatus": "unsupported", + "next_reset": null, "is_default": true, "last_activity_at": null, "today_cost": null, + "health": "ok", "cached": false, "fetchedAt": "2026-06-08T16:40:00.000Z", "needsReauth": false + }] + """ + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(unsupportedJSON.utf8)) + check(rows[0].quotaStatus == "unsupported", "ghcp decodes quotaStatus unsupported") + check(rows[0].health == "ok", "unsupported provider is health ok (no orange dot)") } // MARK: BarDiscovery loading @@ -113,29 +139,106 @@ case .failure(let e): // MARK: BarFormatting -check(BarFormatting.quotaLabel(82.4) == "82%", "quota label rounds") -check(BarFormatting.quotaLabel(nil) == "--", "nil quota -> --") +// Tri-state quota label: never a bare "--". +check(BarFormatting.quotaLabel(percentage: 82.4, status: "ok") == "82%", "ok+pct -> NN%") +check( + BarFormatting.quotaLabel(percentage: nil, status: "unsupported") == "no quota", + "unsupported -> 'no quota'") +check( + BarFormatting.quotaLabel(percentage: nil, status: "error") == "quota ?", "error -> 'quota ?'") check(BarFormatting.costLabel(3.2) == "$3.20", "cost label formats") check(BarFormatting.costLabel(0) == "", "zero cost hidden") check(BarFormatting.costLabel(nil) == "", "nil cost hidden") -do { - let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8)) - let title = BarFormatting.statusTitle(rows: rows) - // Active rows only (bob is paused); alice leads. Total cost = 3.2. - check(title.contains("agy 82%"), "title shows leading active account + quota") - check(title.contains("$3.20"), "title shows total cost") +// MARK: statusTitle fallback chain (NEVER a bare "--", NEVER a lifetime figure when today==0) + +// Shared analytics fixtures for the chain. +func mkAnalytics(allTimeCost: Double, todayCost: Double = 0) -> BarAnalytics { + BarAnalytics( + today: .init(cost: todayCost, requests: todayCost > 0 ? 10 : 0), + last7d: .init(cost: 0, requests: 0), + last30d: .init(cost: 0, requests: 0), + allTime: .init(cost: allTimeCost, requests: 5314), + byDay: [], + topModels: [], + topModelsWindow: "all", + lastActivityAt: "2026-04-29T10:00:00Z", + daysSinceLastActivity: 40, + hasRecentData: false, + generatedAt: "2026-06-08T13:00:00Z") } -// leadRow features the account CLOSEST TO EXHAUSTION (lowest remaining %), -// not the healthiest. quota_percentage is REMAINING quota. -let twoActive = [ - BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, health: "ok"), - BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 30, health: "ok"), +// (1) QUOTA wins: a quota row yields " NN%". +do { + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8)) + let title = BarFormatting.statusTitle(rows: rows, analytics: mkAnalytics(allTimeCost: 2609.1)) + check(title == "agy 82%", "title (1) quota wins -> 'agy 82%'") +} + +// (1) lowest-remaining among quota rows is chosen; unsupported/error skipped. +let quotaMix = [ + BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, quotaStatus: "ok"), + BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 12, quotaStatus: "ok"), + BarSummaryRow(accountId: "c", provider: "ghcp", quotaStatus: "unsupported"), ] -let twoTitle = BarFormatting.statusTitle(rows: twoActive) -check(twoTitle.contains("30%"), "title features lowest-remaining (closest to exhaustion)") -check(!twoTitle.contains("90%"), "title does not feature the healthiest account") +let mixTitle = BarFormatting.statusTitle(rows: quotaMix, analytics: nil) +check(mixTitle == "agy 12%", "title features lowest-remaining quota, skips unsupported") +check(!mixTitle.contains("--"), "quota-mix title never contains '--'") + +// (2) TODAY COST wins when no quota but analytics.today.cost > 0. +// The title reads the fresh analytics aggregate, not per-row today_cost. +let todayRows = [ + BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"), +] +let todayAnalytics = mkAnalytics(allTimeCost: 40844, todayCost: 3.2) +check( + BarFormatting.statusTitle(rows: todayRows, analytics: todayAnalytics) == "$3.20", + "title (2) today cost from analytics -> '$3.20'") + +// (2) today cost zero with non-zero all-time must NOT produce a "~$..." lifetime +// figure — the all-time step was removed to prevent confusion with live spend. +let staleRows = [ + BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported", todayCost: 0), + BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported", todayCost: nil), +] +let staleAnalytics = mkAnalytics(allTimeCost: 2609.1, todayCost: 0) +let staleTitle = BarFormatting.statusTitle(rows: staleRows, analytics: staleAnalytics) +check(!staleTitle.contains("~$"), "today==0 title never shows a '~$' lifetime figure") +check(!staleTitle.contains("2.6k"), "today==0 title never shows the all-time dollar amount") +check(!staleTitle.contains("--"), "stale title never contains '--'") +// With no quota and today==0 and 2 active rows, chain falls to COUNT. +check(staleTitle == "CCS 2", "title (3) no quota + today==0 falls to active count 'CCS 2'") + +// (3) COUNT / ATTENTION when no quota and no today spend. +let reauthRows = [ + BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "error", needsReauth: true), + BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported"), +] +check( + BarFormatting.statusTitle(rows: reauthRows, analytics: mkAnalytics(allTimeCost: 0)) == "CCS 1!", + "title (3) reauth attention -> 'CCS 1!'") +let activeRows = [ + BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"), + BarSummaryRow(accountId: "b", provider: "kiro", paused: true, quotaStatus: "unsupported"), +] +check( + BarFormatting.statusTitle(rows: activeRows, analytics: nil) == "CCS 1", + "title (3) active count -> 'CCS 1'") + +// (4) empty rows -> "CCS". +check(BarFormatting.statusTitle(rows: [], analytics: nil) == "CCS", "title (4) empty -> 'CCS'") + +// All-empty-quota fixture: title never a bare "--". +check( + !BarFormatting.statusTitle(rows: activeRows, analytics: nil).contains("--"), + "no-quota title never contains a bare '--'") + +// leadRow is deterministic with no quota: the is_default row, not rows.first. +let leadRows = [ + BarSummaryRow(accountId: "z", provider: "ghcp", quotaStatus: "unsupported", isDefault: false), + BarSummaryRow(accountId: "a", provider: "kiro", quotaStatus: "unsupported", isDefault: true), +] +check(BarFormatting.leadRow(leadRows)?.accountId == "a", "leadRow prefers is_default row") // MARK: RefreshDebouncer (arms at decision time) @@ -204,20 +307,65 @@ check(BarFormatting.money(0) == "$0.00", "money shows zero") check(BarFormatting.money(2609.1) == "$2.6k", "money compacts thousands") check(BarFormatting.count(5314) == "5.3k", "count compacts thousands") +// Mirrors the stale-snapshot reality: recent windows zero, all-time populated, +// hasRecentData false, last-active in late April, 30-entry zero-filled byDay. +func buildByDayJSON(count: Int) -> String { + (0.. String { - guard let pct else { return "--" } + /// Tri-state quota label. Honest about WHY a percentage is missing instead of + /// collapsing every case to a bare "--": + /// status "ok" + pct → "NN%" (threshold-colored upstream) + /// status "unsupported" → "no quota" (provider has no quota API) + /// status "error" → "quota ?" (transient fetch failure) + /// An "ok" status with a nil percentage (shouldn't happen, but be safe) also + /// degrades to "quota ?" rather than "--". + public static func quotaLabel(percentage pct: Double?, status: String) -> String { + switch status { + case "ok": + guard let pct else { return "quota ?" } + return "\(Int(pct.rounded()))%" + case "unsupported": + return "no quota" + default: + return "quota ?" + } + } + + /// Quota title token: only an "ok" row with a real percentage yields a token + /// (so "unsupported"/"error" rows can never produce "--" in the menu-bar + /// title and the fallback chain falls through instead). Returns nil to skip. + public static func quotaTitleToken(percentage pct: Double?, status: String) -> String? { + guard status == "ok", let pct else { return nil } return "\(Int(pct.rounded()))%" } @@ -31,31 +52,81 @@ public enum BarFormatting { return "\(n)" } - /// Compact status-bar title. Shows the most-used (lowest remaining quota) - /// active account, plus today's total cost when available. - /// Example: "agy 82% · $3.20". Falls back to "CCS" when there are no rows. - public static func statusTitle(rows: [BarSummaryRow]) -> String { - let active = rows.filter { !$0.paused } - guard let lead = leadRow(active.isEmpty ? rows : active) else { return "CCS" } - var parts: [String] = [] - let q = quotaLabel(lead.quotaPercentage) - parts.append("\(lead.provider) \(q)") - let total = rows.compactMap { $0.todayCost }.reduce(0, +) - let cost = costLabel(total) - if !cost.isEmpty { parts.append(cost) } - return parts.joined(separator: " \u{00B7} ") + /// Compact, always-meaningful status-bar title. Evaluates an ordered fallback + /// chain left to right; the first step that yields a non-empty token wins. A + /// bare "--" is NEVER emitted — every step degrades to the next instead. + /// + /// 1. QUOTA — lowest remaining quota among rows whose quotaStatus=="ok" + /// with a real percentage → " NN%" (e.g. "agy 12%"). + /// "unsupported"/"error" rows are skipped so they can't show "--". + /// 2. TODAY COST — else analytics.today.cost > 0 → "$" (e.g. "$3.20"). + /// Uses the fresh aggregate from analytics, not per-row today_cost. + /// 3. ATTENTION/COUNT — else rows needing reauth → "CCS !"; else active + /// (non-paused) count → "CCS ", fallback to total count. + /// 4. "CCS" — only when there are no rows at all. + /// + /// All-time spend is deliberately EXCLUDED from the title chain: a lifetime dollar + /// figure (e.g. "$40.8k") always reads as live spend in the always-on menu bar, + /// creating false urgency. It belongs only in the analytics section of the dropdown. + public static func statusTitle(rows: [BarSummaryRow], analytics: BarAnalytics?) -> String { + if rows.isEmpty { return "CCS" } + + // (1) QUOTA — closest to exhaustion among quota-capable rows. + let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil } + if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }), + let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus) + { + return "\(lead.provider) \(token)" + } + + // (2) TODAY COST — fresh aggregate from analytics (more accurate than summing + // per-row today_cost, which may have nulls or stale snapshot values). + if let todayCost = analytics?.today.cost, todayCost > 0 { + return money(todayCost) + } + + // (3) ATTENTION / ACTIVE COUNT. + let reauthCount = rows.filter { $0.needsReauth }.count + if reauthCount > 0 { + return "CCS \(reauthCount)!" + } + let activeCount = rows.filter { !$0.paused }.count + return "CCS \(activeCount > 0 ? activeCount : rows.count)" } - /// The row to surface in the compact title: the one closest to exhaustion. - /// `quota_percentage` is REMAINING quota (higher = more left), so the lead is - /// the LOWEST remaining percentage. Rows without a known percentage are not - /// chosen unless no row has one. - static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? { - let withPct = rows.filter { $0.quotaPercentage != nil } - if let lead = withPct.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }) { - return lead + /// The "headline" account for the dropdown when no quota exists (which account + /// name leads). Deterministic: prefer the default row, else the sole active + /// row, else alphabetical by id — never `rows.first` (arbitrary order). + public static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? { + if rows.isEmpty { return nil } + if let def = rows.first(where: { $0.isDefault }) { return def } + let active = rows.filter { !$0.paused } + if active.count == 1 { return active[0] } + return rows.min(by: { $0.id < $1.id }) + } + + /// Human "Last active" caption from an ISO timestamp + a precomputed day-delta. + /// "Last active today" / "yesterday" / "Apr 29" — never a raw ISO string. + public static func lastActiveLabel(iso: String?, daysSince: Int?) -> String? { + guard let iso, let date = isoDate(iso) else { return nil } + if let d = daysSince { + if d <= 0 { return "Last active today" } + if d == 1 { return "Last active yesterday" } } - return rows.first + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.dateFormat = "MMM d" + return "Last active \(fmt.string(from: date))" + } + + /// Parse an ISO-8601 timestamp (with or without fractional seconds). + static func isoDate(_ iso: String) -> Date? { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = withFraction.date(from: iso) { return d } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: iso) } } diff --git a/macos-bar/Sources/CCSBarCore/BarSummary.swift b/macos-bar/Sources/CCSBarCore/BarSummary.swift index 58bc3d4e..a1ae7166 100644 --- a/macos-bar/Sources/CCSBarCore/BarSummary.swift +++ b/macos-bar/Sources/CCSBarCore/BarSummary.swift @@ -11,7 +11,17 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { public let tier: String? public let paused: Bool public let quotaPercentage: Double? + /// Tri-state quota availability: "ok" (provider has a quota API and the fetch + /// succeeded), "unsupported" (provider has no quota API at all, e.g. ghcp/kiro), + /// or "error" (should report quota but the fetch failed/timed out/needs reauth). + /// Drives "no quota" (unsupported) vs "quota ?" (error) so a bare "--" never + /// conflates the two. + public let quotaStatus: String public let nextReset: String? + /// True when this is the provider's default account; drives the active/default badge. + public let isDefault: Bool + /// ISO timestamp this account was last used, null if never/unknown. + public let lastActivityAt: String? public let todayCost: Double? public let health: String public let cached: Bool @@ -28,7 +38,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { case tier case paused case quotaPercentage = "quota_percentage" + case quotaStatus case nextReset = "next_reset" + case isDefault = "is_default" + case lastActivityAt = "last_activity_at" case todayCost = "today_cost" case health case cached @@ -43,7 +56,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { tier: String? = nil, paused: Bool = false, quotaPercentage: Double? = nil, + quotaStatus: String = "ok", nextReset: String? = nil, + isDefault: Bool = false, + lastActivityAt: String? = nil, todayCost: Double? = nil, health: String = "ok", cached: Bool = false, @@ -56,7 +72,10 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { self.tier = tier self.paused = paused self.quotaPercentage = quotaPercentage + self.quotaStatus = quotaStatus self.nextReset = nextReset + self.isDefault = isDefault + self.lastActivityAt = lastActivityAt self.todayCost = todayCost self.health = health self.cached = cached