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.
This commit is contained in:
Tam Nhu Tran
2026-06-09 11:49:55 -04:00
parent 5f850c4899
commit 4a633eac65
7 changed files with 595 additions and 106 deletions
@@ -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