diff --git a/macos-bar/Resources/Assets/HeaderLogo.png b/macos-bar/Resources/Assets/HeaderLogo.png new file mode 100644 index 00000000..ab9f8c09 Binary files /dev/null and b/macos-bar/Resources/Assets/HeaderLogo.png differ diff --git a/macos-bar/Resources/Assets/MenuBarColor.png b/macos-bar/Resources/Assets/MenuBarColor.png new file mode 100644 index 00000000..af76e79c Binary files /dev/null and b/macos-bar/Resources/Assets/MenuBarColor.png differ diff --git a/macos-bar/Resources/Assets/MenuBarTemplate.png b/macos-bar/Resources/Assets/MenuBarTemplate.png new file mode 100644 index 00000000..9fac98fd Binary files /dev/null and b/macos-bar/Resources/Assets/MenuBarTemplate.png differ 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 {