Merge pull request #1479 from kaitranntt/kai/feat/ccs-bar-analytics-overhaul

feat(bar): merged multi-source analytics, per-surface breakdown, UI overhaul
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-09 11:51:11 -04:00
commit 7f0640b4ca
12 files changed
+1031 -132

No files matched your search

@@ -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)
}
}
+115 -34
View File
@@ -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 <date>" 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.
@@ -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
+171 -23
View File
@@ -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 "<provider> 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..<count).map { i in "{\"date\":\"2026-05-\(String(format: "%02d", i + 1))\",\"cost\":0,\"requests\":0}" }
.joined(separator: ",")
}
let analyticsJSON = """
{"today":{"cost":0,"requests":0},"last7d":{"cost":0,"requests":0},
"last30d":{"cost":0,"requests":0},"allTime":{"cost":2609.1,"requests":5314},
"byDay":[{"date":"2026-06-08","cost":0,"requests":0}],
"byDay":[\(buildByDayJSON(count: 30))],
"topModels":[{"model":"gpt-5.4","cost":1253.65,"requests":1306}],
"topModelsWindow":"all","generatedAt":"2026-06-08T13:00:00.000Z"}
"topModelsWindow":"all","lastActivityAt":"2026-04-29T10:00:00.000Z",
"daysSinceLastActivity":40,"hasRecentData":false,
"generatedAt":"2026-06-08T13:00:00.000Z"}
""".data(using: .utf8)!
do {
let a = try JSONDecoder().decode(BarAnalytics.self, from: analyticsJSON)
check(a.allTime.requests == 5314, "analytics decodes all-time requests")
check(a.topModels.first?.model == "gpt-5.4", "analytics decodes top model")
check(a.topModelsWindow == "all", "analytics decodes window")
check(a.byDay.count == 30, "analytics decodes 30-day byDay series")
check(a.lastActivityAt == "2026-04-29T10:00:00.000Z", "analytics decodes lastActivityAt")
check(a.daysSinceLastActivity == 40, "analytics decodes daysSinceLastActivity")
check(a.hasRecentData == false, "analytics decodes hasRecentData (false on idle)")
// bySurface absent from JSON → defaults to empty array (resilient decoding).
check(a.bySurface.isEmpty, "analytics bySurface defaults to [] when absent from payload")
}
// MARK: BarAnalyticsSurface decoding
// Verify bySurface round-trips correctly with the exact field names the backend
// sends: `source`, `surface`, `cost`, `requests` (all camelCase / exact match).
let surfaceAnalyticsJSON = """
{"today":{"cost":1963.70,"requests":13103},"last7d":{"cost":1963.70,"requests":13103},
"last30d":{"cost":35620.0,"requests":70973},"allTime":{"cost":40844.0,"requests":91207},
"byDay":[\(buildByDayJSON(count: 30))],
"topModels":[],"topModelsWindow":"30d",
"hasRecentData":true,"generatedAt":"2026-06-09T00:00:00.000Z",
"bySurface":[
{"source":"custom-parser","surface":"Claude Code","cost":33656.21,"requests":57870},
{"source":"codex-native","surface":"Codex","cost":1963.70,"requests":13103}
]}
""".data(using: .utf8)!
do {
let a = try JSONDecoder().decode(BarAnalytics.self, from: surfaceAnalyticsJSON)
check(a.bySurface.count == 2, "bySurface decodes 2 surfaces")
check(a.bySurface[0].surface == "Claude Code", "bySurface[0].surface decodes")
check(a.bySurface[0].source == "custom-parser", "bySurface[0].source decodes")
check(a.bySurface[0].cost == 33656.21, "bySurface[0].cost decodes")
check(a.bySurface[0].requests == 57870, "bySurface[0].requests decodes")
check(a.bySurface[1].surface == "Codex", "bySurface[1].surface decodes")
check(a.bySurface[1].cost == 1963.70, "bySurface[1].cost decodes")
// Stable identity for SwiftUI ForEach: surface name is the id.
check(a.bySurface[0].id == "Claude Code", "bySurface[0].id == surface name")
// Today cost from the surface-bearing payload.
check(a.today.cost == 1963.70, "surface analytics decodes today.cost")
check(a.hasRecentData == true, "surface analytics decodes hasRecentData true")
} catch {
check(false, "analytics decode threw: \(error)")
check(false, "surface analytics decode threw: \(error)")
}
// cleanup
@@ -4,6 +4,25 @@ import Foundation
///
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
/// values are USD.
/// Spend and request count for one usage surface (tool/origin), e.g. "Claude Code"
/// or "Codex". The server sends these ordered descending by cost for the same window
/// as topModels, so the array can be rendered as-is.
public struct BarAnalyticsSurface: Codable, Sendable, Equatable, Identifiable {
public let source: String
public let surface: String
public let cost: Double
public let requests: Int
/// Stable identity for SwiftUI lists: surface name is unique within a window.
public var id: String { surface }
public init(source: String, surface: String, cost: Double, requests: Int) {
self.source = source
self.surface = surface
self.cost = cost
self.requests = requests
}
}
public struct BarAnalytics: Codable, Sendable, Equatable {
public struct Window: Codable, Sendable, Equatable {
public let cost: Double
@@ -42,11 +61,23 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
public let last7d: Window
public let last30d: Window
public let allTime: Window
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
public let byDay: [Day]
public let topModels: [Model]
/// "30d" when recent data exists, else "all".
public let topModelsWindow: String
/// ISO timestamp of the most recent non-failed usage record, null if none.
public let lastActivityAt: String?
/// Whole local-days since `lastActivityAt`, null if no usable records.
public let daysSinceLastActivity: Int?
/// True when the trailing 30 days carry any spend or requests. The UI pivots
/// its empty/stale presentation on this without re-deriving it.
public let hasRecentData: Bool
public let generatedAt: String
/// Spend/requests per usage surface (e.g. "Claude Code", "Codex"), ordered
/// descending by cost for the same window as topModels. Empty when the backend
/// has no surface breakdown; the UI omits the section in that case.
public let bySurface: [BarAnalyticsSurface]
public init(
today: Window,
@@ -56,7 +87,11 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
byDay: [Day],
topModels: [Model],
topModelsWindow: String,
generatedAt: String
lastActivityAt: String? = nil,
daysSinceLastActivity: Int? = nil,
hasRecentData: Bool = false,
generatedAt: String,
bySurface: [BarAnalyticsSurface] = []
) {
self.today = today
self.last7d = last7d
@@ -65,6 +100,29 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
self.byDay = byDay
self.topModels = topModels
self.topModelsWindow = topModelsWindow
self.lastActivityAt = lastActivityAt
self.daysSinceLastActivity = daysSinceLastActivity
self.hasRecentData = hasRecentData
self.generatedAt = generatedAt
self.bySurface = bySurface
}
// Custom decoder: `bySurface` is a new field absent from older snapshots.
// Defaulting to [] when the key is missing keeps the app backward-compatible
// with any cached or older-backend analytics payload.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
today = try c.decode(Window.self, forKey: .today)
last7d = try c.decode(Window.self, forKey: .last7d)
last30d = try c.decode(Window.self, forKey: .last30d)
allTime = try c.decode(Window.self, forKey: .allTime)
byDay = try c.decode([Day].self, forKey: .byDay)
topModels = try c.decode([Model].self, forKey: .topModels)
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
daysSinceLastActivity = try c.decodeIfPresent(Int.self, forKey: .daysSinceLastActivity)
hasRecentData = try c.decode(Bool.self, forKey: .hasRecentData)
generatedAt = try c.decode(String.self, forKey: .generatedAt)
bySurface = (try c.decodeIfPresent([BarAnalyticsSurface].self, forKey: .bySurface)) ?? []
}
}
@@ -3,9 +3,30 @@ import Foundation
/// Pure formatting helpers for the status-bar title and dropdown rows.
/// No SwiftUI dependency so they are unit-testable on any toolchain.
public enum BarFormatting {
/// Quota percentage label, e.g. "82%" or "--" when unknown.
public static func quotaLabel(_ pct: Double?) -> 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 → "<provider> NN%" (e.g. "agy 12%").
/// "unsupported"/"error" rows are skipped so they can't show "--".
/// 2. TODAY COST — else analytics.today.cost > 0 → "$<today>" (e.g. "$3.20").
/// Uses the fresh aggregate from analytics, not per-row today_cost.
/// 3. ATTENTION/COUNT — else rows needing reauth → "CCS <n>!"; else active
/// (non-paused) count → "CCS <n>", 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)
}
}
@@ -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
+4
View File
@@ -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',
};
}
+84 -15
View File
@@ -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<string, number>;
/** Load persisted CLIProxy usage details (from snapshot cache) */
loadCliproxyDetails: () => Promise<CliproxyUsageHistoryDetail[]>;
/**
* Load merged, multi-source daily usage (Claude Code, Codex, Droid, CLIProxy).
* Fresh, stale-while-revalidate; carries cost + per-model + per-surface spend.
*/
loadDailyUsage: () => Promise<DailyUsage[]>;
/** Load merged hourly usage — the source of request counts (daily lacks them). */
loadHourlyUsage: () => Promise<HourlyUsage[]>;
/**
* 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<T>(p: Promise<T>, ms: number): Promise<T | null> {
// 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<T>(p: Promise<T>, ms: number): Promise<T | null> {
* 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<void> => {
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;
+220 -2
View File
@@ -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<string, string> = {
'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<string, BarAnalyticsDay>();
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<string, BarAnalyticsModel>();
const modelAll = new Map<string, BarAnalyticsModel>();
const bumpModel = (map: Map<string, BarAnalyticsModel>, 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<string, BarAnalyticsSurface>();
const surfaceAll = new Map<string, BarAnalyticsSurface>();
const bumpSurface = (
map: Map<string, BarAnalyticsSurface>,
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(),
};
}
+41 -4
View File
@@ -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(
[
+87 -5
View File
@@ -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<BarSummaryRow[]>(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<BarSummaryRow[]>(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<BarSummaryRow[]>(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<BarSummaryRow[]>(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<BarSummaryRow[]>(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<BarSummaryRow[]>(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<void>((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(),
});