From 9b64514a57f78ff101333caa3daa8c600a48f541 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 8 Jun 2026 09:31:43 -0400 Subject: [PATCH] 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 --- macos-bar/Resources/Assets/HeaderLogo.png | Bin 0 -> 6228 bytes macos-bar/Resources/Assets/MenuBarColor.png | Bin 0 -> 3614 bytes .../Resources/Assets/MenuBarTemplate.png | Bin 0 -> 1646 bytes macos-bar/Scripts/package_app.sh | 6 + .../Sources/CCSBarApp/BarAnalyticsView.swift | 111 +++++++++++ macos-bar/Sources/CCSBarApp/BarMenuView.swift | 183 +++++++++++++----- .../Sources/CCSBarApp/BarViewModel.swift | 16 ++ macos-bar/Sources/CCSBarApp/CCSBarApp.swift | 3 + macos-bar/Sources/CCSBarApp/MenuBarIcon.swift | 55 ++++++ macos-bar/Sources/CCSBarApp/Sparkline.swift | 28 +++ macos-bar/Sources/CCSBarCheck/main.swift | 21 ++ .../Sources/CCSBarCore/BarAnalytics.swift | 70 +++++++ .../Sources/CCSBarCore/BarFormatting.swift | 16 ++ .../Sources/CCSBarCore/CCSBarClient.swift | 13 ++ 14 files changed, 477 insertions(+), 45 deletions(-) create mode 100644 macos-bar/Resources/Assets/HeaderLogo.png create mode 100644 macos-bar/Resources/Assets/MenuBarColor.png create mode 100644 macos-bar/Resources/Assets/MenuBarTemplate.png create mode 100644 macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift create mode 100644 macos-bar/Sources/CCSBarApp/MenuBarIcon.swift create mode 100644 macos-bar/Sources/CCSBarApp/Sparkline.swift create mode 100644 macos-bar/Sources/CCSBarCore/BarAnalytics.swift diff --git a/macos-bar/Resources/Assets/HeaderLogo.png b/macos-bar/Resources/Assets/HeaderLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..ab9f8c096b72c9d2f926ba04186c724c6f264b1c GIT binary patch literal 6228 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 R|NR*Na3xK}a(Ro;{{giUtAzjn literal 0 HcmV?d00001 diff --git a/macos-bar/Resources/Assets/MenuBarColor.png b/macos-bar/Resources/Assets/MenuBarColor.png new file mode 100644 index 0000000000000000000000000000000000000000..af76e79cd269c4ae1cb7e9d4090f14ffd53ea488 GIT binary patch literal 3614 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 O0d-X!m0Bg6=>Gv*dA!^J literal 0 HcmV?d00001 diff --git a/macos-bar/Resources/Assets/MenuBarTemplate.png b/macos-bar/Resources/Assets/MenuBarTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..9fac98fde2ec304e36d1f317e1280150be0daac2 GIT binary patch literal 1646 zcmZ`&c~H^`6#kW{cxFUfwyDe}N@|HHlBi8fm>|lEN2PeALLR7qQU;}!LXl^NX|76T zsbu0>rsaiJXwIsc2$p%YR$g?Nm6?|7va>Tgvw!T&d+(d~z4_+*1O1`WWX2!1{Qz_S8?Q+xne)=;Nr0N@A`0Mv&70DB1lHnB>Z zDQ=p>+DMWo9#}n_*W0ey^jL(TWB@RJ_;;^)#FWr9q%Mc(Whs`s{ZUm9ujS^w++CfFgvumX{C2Lz7!6z5{YRN8RnSD0yCvg|yWd+h16kYsvFnhPVPbOP0n7Rj*9b zVBCwR6Qa(zJ6aaSwY|0kVO}9;ZT4=O?GUkjaT(6xO0F%dy2rTX$!}I0n4QT)urAfo z3GESmFYx0#u-;Fpo_mv@?w9v|FAGx~bs^q5GeJwZ;`Hhs4RmkqMvCrnt&NXQdwhW6 z^oVxp>x_DZ)p%_*=gO*lJ{N>Uq73mT+@aK~O5k?Ai98fGY6$kGnN+#QtcZ=YSF={G z?Av~M!{%odvu1Z}>gbTu-;3*y7sxWv+p5zM+h!y6&J8YpBS=|9Q+J*)NCNuV260RUPzi1?oa2#<19Om7t(s{5h8twYeG&4*qD8}&W!L*`e&nQ;5H!v&?H!;|YtY>H#1gwesREi3JXzK8B5{(iC-93^ zgfX=ie^9hp0w%dz`hZFgLs~_HG^f_*T~4BIqgoxfrwMDVA z$z39yga6Ja*K6;T=b-+1^GeR znx;j6^Exi0-Fx_+@1|@ z7(TjE{BnEUP-np}Jjqxz=|cgE&{*8~rsrz}bF&}hp_dG9Y^ViUte?&%-Q8i^ncQj0 z9)vOmZ0szr_QS1LX0+zwlx1SC>e2>J0ebiRMg5AT1sAD`y(%PSpBVy9NOR0__yC!t elwt0l`Q4!3o{31kUGZJ>=m61!jIYHVO8*OtYTJAO literal 0 HcmV?d00001 diff --git a/macos-bar/Scripts/package_app.sh b/macos-bar/Scripts/package_app.sh index e23927d1..39f34466 100755 --- a/macos-bar/Scripts/package_app.sh +++ b/macos-bar/Scripts/package_app.sh @@ -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) diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift new file mode 100644 index 00000000..ae45daf8 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -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) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 156b4991..d9cdb32e 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -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) } } diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index 785fa2b4..4f46353a 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -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 diff --git a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift index 91ef8824..3e2c6eac 100644 --- a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift +++ b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift @@ -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) diff --git a/macos-bar/Sources/CCSBarApp/MenuBarIcon.swift b/macos-bar/Sources/CCSBarApp/MenuBarIcon.swift new file mode 100644 index 00000000..b2395c8f --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/MenuBarIcon.swift @@ -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() + } +} diff --git a/macos-bar/Sources/CCSBarApp/Sparkline.swift b/macos-bar/Sources/CCSBarApp/Sparkline.swift new file mode 100644 index 00000000..81e64476 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/Sparkline.swift @@ -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 +} diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 77e49a70..7c619536 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -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) diff --git a/macos-bar/Sources/CCSBarCore/BarAnalytics.swift b/macos-bar/Sources/CCSBarCore/BarAnalytics.swift new file mode 100644 index 00000000..2e3d03cb --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarAnalytics.swift @@ -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 + } +} diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift index 75cda29c..3ee14085 100644 --- a/macos-bar/Sources/CCSBarCore/BarFormatting.swift +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -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. diff --git a/macos-bar/Sources/CCSBarCore/CCSBarClient.swift b/macos-bar/Sources/CCSBarCore/CCSBarClient.swift index b0fd5773..95ea52a7 100644 --- a/macos-bar/Sources/CCSBarCore/CCSBarClient.swift +++ b/macos-bar/Sources/CCSBarCore/CCSBarClient.swift @@ -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 {