feat(bar-app): add CCS icon variants and usage analytics UI

- Menu-bar icon from the CCS logo: color mark + monochrome template that
  macOS auto-tints; footer toggle, persisted in UserDefaults
- Dropdown analytics section: today/7d/30d/all-time spend, 7-day sparkline,
  top-model bars (BarAnalytics model + client + view)
- Polished rows: branded header, colored health dots, provider/tier chips
- package_app.sh bundles the icon assets into the .app
- assert harness covers analytics decode + compact money/count formatting
This commit is contained in:
Tam Nhu Tran
2026-06-08 09:31:43 -04:00
parent 78f9fc7c0b
commit 9b64514a57
14 changed files with 477 additions and 45 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+6
View File
@@ -36,6 +36,12 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
# Bundle the CCS icon assets (menu-bar color/template + header logo) so
# Bundle.main can resolve them at runtime.
if [[ -d "$ROOT/Resources/Assets" ]]; then
cp "$ROOT/Resources/Assets/"*.png "$APP/Contents/Resources/" 2>/dev/null || true
fi
echo "[i] Signing ($SIGNING)..."
case "$SIGNING" in
adhoc)
@@ -0,0 +1,111 @@
import SwiftUI
import CCSBarCore
/// Usage analytics block: spend rollups, a 7-day cost sparkline, and top models.
struct BarAnalyticsView: View {
let analytics: BarAnalytics
var body: some View {
VStack(alignment: .leading, spacing: 10) {
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)
}
}
// 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)
}
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 30)
}
// Top models.
if !analytics.topModels.isEmpty {
let scope = analytics.topModelsWindow == "30d" ? "30d" : "all-time"
SectionLabel("Top models · \(scope)")
let peak = analytics.topModels.map(\.cost).max() ?? 1
ForEach(analytics.topModels.prefix(4)) { model in
ModelBar(model: model, peak: peak)
}
}
}
}
}
/// A small labelled stat tile.
private struct StatCell: View {
let title: String
let value: String
var accent: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
Text(value)
.font(.system(.callout, design: .rounded).weight(.semibold))
.foregroundStyle(accent ? BarTheme.accent : .primary)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.padding(.horizontal, 9)
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7))
}
}
/// One top-model row: name + spend with a proportional accent bar behind.
private struct ModelBar: View {
let model: BarAnalytics.Model
let peak: Double
var body: some View {
GeometryReader { geo in
let fraction = peak > 0 ? CGFloat(model.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(model.model)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(BarFormatting.money(model.cost))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
}
.frame(height: 22)
}
}
/// Uppercase section divider label.
struct SectionLabel: View {
let text: String
init(_ text: String) { self.text = text }
var body: some View {
Text(text.uppercased())
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
+138 -45
View File
@@ -2,95 +2,166 @@ import SwiftUI
import AppKit
import CCSBarCore
/// Dropdown content for the menu bar: per-account rows + actions, an offline
/// state when CCS isn't running, and footer controls.
/// Dropdown content for the menu bar: a CCS-branded header, usage analytics,
/// per-account rows + actions, an offline state when CCS isn't running, and
/// footer controls.
struct BarMenuView: View {
@ObservedObject var viewModel: BarViewModel
var body: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
if viewModel.offline {
offlineState
} else if viewModel.rows.isEmpty {
Text("No accounts found")
.foregroundStyle(.secondary)
offlineState.padding(14)
} else {
ForEach(viewModel.rows) { row in
BarRowView(row: row, viewModel: viewModel)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if let analytics = viewModel.analytics {
BarAnalyticsView(analytics: analytics)
}
VStack(alignment: .leading, spacing: 8) {
SectionLabel("Accounts")
if viewModel.rows.isEmpty {
Text("No accounts configured")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.rows) { row in
BarRowView(row: row, viewModel: viewModel)
}
}
}
}
.padding(14)
}
.frame(maxHeight: 520)
}
Divider()
footer
}
.padding(12)
.frame(width: 320)
.frame(width: 340)
.onAppear { viewModel.onOpen() }
}
private var header: some View {
HStack {
Text("CCS").font(.headline)
HStack(spacing: 8) {
Image(nsImage: MenuBarIcon.headerImage())
VStack(alignment: .leading, spacing: 0) {
Text("CCS").font(.headline)
Text("usage & accounts").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if viewModel.isRefreshing {
Text("refreshing…").font(.caption).foregroundStyle(.secondary)
ProgressView().controlSize(.small)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
private var offlineState: some View {
VStack(alignment: .leading, spacing: 6) {
Text("CCS is not running").font(.body)
Label("CCS is not running", systemImage: "bolt.slash.fill")
.font(.body)
Text("Start CCS, then reopen this menu.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
.controlSize(.small)
}
}
private var footer: some View {
VStack(alignment: .leading, spacing: 4) {
Button("Open dashboard") { openDashboard() }
Button("Refresh") { viewModel.onOpen() }
Button("Quit") { NSApplication.shared.terminate(nil) }
HStack(spacing: 10) {
Button {
openDashboard()
} label: {
Label("Dashboard", systemImage: "chart.bar.xaxis")
}
Button {
viewModel.toggleIconStyle()
} label: {
Label(
viewModel.iconStyle == .color ? "Color" : "Mono",
systemImage: viewModel.iconStyle == .color ? "paintpalette" : "circle.lefthalf.filled"
)
}
.help("Toggle the menu-bar icon between color and monochrome")
Spacer()
Button {
viewModel.onOpen()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Refresh")
Button {
NSApplication.shared.terminate(nil)
} label: {
Image(systemName: "power")
}
.help("Quit CCS Bar")
}
.buttonStyle(.borderless)
.font(.caption)
.padding(.horizontal, 14)
.padding(.vertical, 9)
}
private func openDashboard() {
// The dashboard runs on the same host/port the bar reads from discovery.
if case .success(let discovery) = BarDiscovery.load(), let url = discovery.resolvedURL {
NSWorkspace.shared.open(url)
}
}
}
/// One account row: health dot, name, provider/tier/quota/paused subline, and
/// One account row: colored health dot, name, a chip subline, today's cost, and
/// a control menu.
struct BarRowView: View {
let row: BarSummaryRow
@ObservedObject var viewModel: BarViewModel
var body: some View {
HStack(alignment: .top) {
Text(row.healthDot)
.font(.system(.caption, design: .monospaced))
.frame(width: 22, alignment: .leading)
HStack(alignment: .center, spacing: 9) {
Circle()
.fill(healthColor)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(row.displayName ?? row.accountId)
.font(.body)
.lineLimit(1)
Text(subline)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(row.displayName ?? row.accountId)
.font(.system(.body, design: .default).weight(.medium))
.lineLimit(1)
.truncationMode(.middle)
if row.paused {
Chip("paused", tint: .secondary)
}
if row.needsReauth {
Chip("reauth", tint: .red)
}
}
HStack(spacing: 6) {
Chip(row.provider, tint: BarTheme.accent)
if let tier = row.tier { Chip(tier, tint: .secondary) }
Text(BarFormatting.quotaLabel(row.quotaPercentage))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Menu("Actions") {
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 {
@@ -98,24 +169,46 @@ struct BarRowView: View {
}
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)
.frame(width: 90)
.menuIndicator(.hidden)
.frame(width: 28)
}
.padding(.vertical, 5)
.padding(.horizontal, 8)
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
}
private var subline: String {
var parts: [String] = [row.provider]
if let tier = row.tier { parts.append(tier) }
parts.append(BarFormatting.quotaLabel(row.quotaPercentage))
let cost = BarFormatting.costLabel(row.todayCost)
if !cost.isEmpty { parts.append(cost) }
if row.paused { parts.append("paused") }
if row.needsReauth { parts.append("needs reauth") }
return parts.joined(separator: " \u{00B7} ")
private var healthColor: Color {
switch row.health {
case "error": return .red
case "warning": return .orange
default: return .green
}
}
}
/// Small pill label used in account sublines.
struct Chip: View {
let text: String
let tint: Color
init(_ text: String, tint: Color) {
self.text = text
self.tint = tint
}
var body: some View {
Text(text)
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(tint.opacity(0.16), in: Capsule())
.foregroundStyle(tint == .secondary ? Color.secondary : tint)
}
}
@@ -8,9 +8,13 @@ import CCSBarCore
@MainActor
final class BarViewModel: ObservableObject {
@Published var rows: [BarSummaryRow] = []
@Published var analytics: BarAnalytics?
@Published var offline = false
@Published var lastError: String?
@Published var isRefreshing = false
@Published var iconStyle: BarIconStyle {
didSet { MenuBarIcon.saveStyle(iconStyle) }
}
private let home: String
private var client: CCSBarClient?
@@ -18,9 +22,15 @@ final class BarViewModel: ObservableObject {
init(home: String = NSHomeDirectory()) {
self.home = home
self.iconStyle = MenuBarIcon.loadStyle()
reconnect()
}
/// Toggle the menu-bar icon between the color mark and the mono template.
func toggleIconStyle() {
iconStyle = (iconStyle == .color) ? .mono : .color
}
/// Compact status-bar title.
var statusTitle: String {
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
@@ -69,6 +79,12 @@ final class BarViewModel: ObservableObject {
// offline state when there is nothing to show.
if rows.isEmpty { offline = true }
}
// Analytics is a best-effort side-load: a failure here must never blank the
// glance or flip us offline. Keep the last-known analytics on error.
if let fresh = try? await client.analytics() {
analytics = fresh
}
}
// MARK: Account actions
@@ -11,6 +11,9 @@ struct CCSBarApp: App {
MenuBarExtra {
BarMenuView(viewModel: viewModel)
} label: {
// The CCS mark + compact glance. The image re-renders when the style
// preference changes because `iconStyle` is observed.
Image(nsImage: MenuBarIcon.statusImage(viewModel.iconStyle))
Text(viewModel.statusTitle)
}
.menuBarExtraStyle(.window)
@@ -0,0 +1,55 @@
import AppKit
/// Menu-bar icon style. `color` shows the full CCS mark; `mono` uses a template
/// silhouette that macOS auto-tints black/white to match the menu bar.
enum BarIconStyle: String, CaseIterable {
case color
case mono
}
/// Loads the CCS icon assets bundled into the .app (Contents/Resources) and
/// hands back correctly-sized NSImages. Falls back to an SF Symbol when running
/// from `swift run` (no bundle), so the app is always usable in dev.
enum MenuBarIcon {
static let defaultsKey = "ccsbar.iconStyle"
static func loadStyle() -> BarIconStyle {
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? BarIconStyle.color.rawValue
return BarIconStyle(rawValue: raw) ?? .color
}
static func saveStyle(_ style: BarIconStyle) {
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
}
/// The status-bar label image at ~18pt for the given style.
static func statusImage(_ style: BarIconStyle) -> NSImage {
let asset = style == .mono ? "MenuBarTemplate" : "MenuBarColor"
let image = bundleImage(asset) ?? sfSymbol("gauge.with.dots.needle.bottom.50percent")
image.size = NSSize(width: 18, height: 18)
image.isTemplate = (style == .mono)
return image
}
/// The color CCS mark for the dropdown header at ~24pt.
static func headerImage() -> NSImage {
let image = bundleImage("HeaderLogo") ?? sfSymbol("gauge.with.dots.needle.bottom.50percent")
image.size = NSSize(width: 24, height: 24)
image.isTemplate = false
return image
}
private static func bundleImage(_ name: String) -> NSImage? {
guard
let url = Bundle.main.url(forResource: name, withExtension: "png"),
let image = NSImage(contentsOf: url)
else { return nil }
return image
}
private static func sfSymbol(_ name: String) -> NSImage {
NSImage(systemSymbolName: name, accessibilityDescription: "CCS")
?? NSImage(systemSymbolName: "circle", accessibilityDescription: "CCS")
?? NSImage()
}
}
@@ -0,0 +1,28 @@
import SwiftUI
/// A compact bar sparkline for daily values (e.g. cost per day over 7 days).
/// Zero-value days render as faint placeholders so the cadence stays readable.
struct Sparkline: View {
let values: [Double]
var accent: Color = BarTheme.accent
var body: some View {
GeometryReader { geo in
let peak = max(values.max() ?? 0, 0.0001)
HStack(alignment: .bottom, spacing: 3) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
let height = CGFloat(value / peak) * geo.size.height
RoundedRectangle(cornerRadius: 2)
.fill(value > 0 ? accent : Color.secondary.opacity(0.2))
.frame(height: max(2, height))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
}
}
/// Shared visual tokens for the menu. The accent matches the CCS logo orange.
enum BarTheme {
static let accent = Color(red: 0.886, green: 0.451, blue: 0.137) // ~#E2732A
}
+21
View File
@@ -199,6 +199,27 @@ do {
}
recorder.status = 200
// Analytics formatting + decode.
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")
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}],
"topModels":[{"model":"gpt-5.4","cost":1253.65,"requests":1306}],
"topModelsWindow":"all","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")
} catch {
check(false, "analytics decode threw: \(error)")
}
// cleanup
try? FileManager.default.removeItem(atPath: tmp)
@@ -0,0 +1,70 @@
import Foundation
/// Usage analytics for the menu bar, mirroring `GET /api/bar/analytics`.
///
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
/// values are USD.
public struct BarAnalytics: Codable, Sendable, Equatable {
public struct Window: Codable, Sendable, Equatable {
public let cost: Double
public let requests: Int
public init(cost: Double, requests: Int) {
self.cost = cost
self.requests = requests
}
}
public struct Day: Codable, Sendable, Equatable, Identifiable {
public let date: String
public let cost: Double
public let requests: Int
public var id: String { date }
public init(date: String, cost: Double, requests: Int) {
self.date = date
self.cost = cost
self.requests = requests
}
}
public struct Model: Codable, Sendable, Equatable, Identifiable {
public let model: String
public let cost: Double
public let requests: Int
public var id: String { model }
public init(model: String, cost: Double, requests: Int) {
self.model = model
self.cost = cost
self.requests = requests
}
}
public let today: Window
public let last7d: Window
public let last30d: Window
public let allTime: Window
public let byDay: [Day]
public let topModels: [Model]
/// "30d" when recent data exists, else "all".
public let topModelsWindow: String
public let generatedAt: String
public init(
today: Window,
last7d: Window,
last30d: Window,
allTime: Window,
byDay: [Day],
topModels: [Model],
topModelsWindow: String,
generatedAt: String
) {
self.today = today
self.last7d = last7d
self.last30d = last30d
self.allTime = allTime
self.byDay = byDay
self.topModels = topModels
self.topModelsWindow = topModelsWindow
self.generatedAt = generatedAt
}
}
@@ -15,6 +15,22 @@ public enum BarFormatting {
return String(format: "$%.2f", cost)
}
/// Always-visible compact currency, e.g. "$0.00", "$12.34", "$2.6k", "$1.3M".
/// Used for the analytics rollups where zero is meaningful (no spend yet).
public static func money(_ v: Double) -> String {
let n = max(0, v)
if n >= 1_000_000 { return String(format: "$%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "$%.1fk", n / 1_000) }
return String(format: "$%.2f", n)
}
/// Compact integer count, e.g. "5", "1.2k", "3.4M".
public static func count(_ n: Int) -> String {
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
if n >= 1_000 { return String(format: "%.1fk", Double(n) / 1_000) }
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.
@@ -58,6 +58,19 @@ public struct CCSBarClient {
}
}
/// GET /api/bar/analytics. Server-side rollup of the usage snapshot
/// (today / 7d / 30d / all-time spend, sparkline, top models).
public func analytics() async throws -> BarAnalytics {
let url = baseURL.appendingPathComponent("api/bar/analytics")
let (data, http) = try await transport.send(URLRequest(url: url))
guard http.statusCode == 200 else { throw CCSBarClientError.httpStatus(http.statusCode) }
do {
return try JSONDecoder().decode(BarAnalytics.self, from: data)
} catch {
throw CCSBarClientError.decoding
}
}
// MARK: Account control (reuses existing CCS endpoints)
public func pause(provider: String, accountId: String) async throws {