mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
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:
1 parent
78f9fc7c0b
commit
9b64514a57
14 files changed
+477
-45
No files matched your search
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 |
@@ -36,6 +36,12 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
|||||||
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
|
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
|
||||||
sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
|
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)..."
|
echo "[i] Signing ($SIGNING)..."
|
||||||
case "$SIGNING" in
|
case "$SIGNING" in
|
||||||
adhoc)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,95 +2,166 @@ import SwiftUI
|
|||||||
import AppKit
|
import AppKit
|
||||||
import CCSBarCore
|
import CCSBarCore
|
||||||
|
|
||||||
/// Dropdown content for the menu bar: per-account rows + actions, an offline
|
/// Dropdown content for the menu bar: a CCS-branded header, usage analytics,
|
||||||
/// state when CCS isn't running, and footer controls.
|
/// per-account rows + actions, an offline state when CCS isn't running, and
|
||||||
|
/// footer controls.
|
||||||
struct BarMenuView: View {
|
struct BarMenuView: View {
|
||||||
@ObservedObject var viewModel: BarViewModel
|
@ObservedObject var viewModel: BarViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header
|
header
|
||||||
|
Divider()
|
||||||
|
|
||||||
if viewModel.offline {
|
if viewModel.offline {
|
||||||
offlineState
|
offlineState.padding(14)
|
||||||
} else if viewModel.rows.isEmpty {
|
|
||||||
Text("No accounts found")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.rows) { row in
|
ScrollView {
|
||||||
BarRowView(row: row, viewModel: viewModel)
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Divider()
|
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
|
footer
|
||||||
}
|
}
|
||||||
.padding(12)
|
.frame(width: 340)
|
||||||
.frame(width: 320)
|
|
||||||
.onAppear { viewModel.onOpen() }
|
.onAppear { viewModel.onOpen() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack {
|
HStack(spacing: 8) {
|
||||||
Text("CCS").font(.headline)
|
Image(nsImage: MenuBarIcon.headerImage())
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("CCS").font(.headline)
|
||||||
|
Text("usage & accounts").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if viewModel.isRefreshing {
|
if viewModel.isRefreshing {
|
||||||
Text("refreshing…").font(.caption).foregroundStyle(.secondary)
|
ProgressView().controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var offlineState: some View {
|
private var offlineState: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
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.")
|
Text("Start CCS, then reopen this menu.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
||||||
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack(spacing: 10) {
|
||||||
Button("Open dashboard") { openDashboard() }
|
Button {
|
||||||
Button("Refresh") { viewModel.onOpen() }
|
openDashboard()
|
||||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
} 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() {
|
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 {
|
if case .success(let discovery) = BarDiscovery.load(), let url = discovery.resolvedURL {
|
||||||
NSWorkspace.shared.open(url)
|
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.
|
/// a control menu.
|
||||||
struct BarRowView: View {
|
struct BarRowView: View {
|
||||||
let row: BarSummaryRow
|
let row: BarSummaryRow
|
||||||
@ObservedObject var viewModel: BarViewModel
|
@ObservedObject var viewModel: BarViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .center, spacing: 9) {
|
||||||
Text(row.healthDot)
|
Circle()
|
||||||
.font(.system(.caption, design: .monospaced))
|
.fill(healthColor)
|
||||||
.frame(width: 22, alignment: .leading)
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(row.displayName ?? row.accountId)
|
HStack(spacing: 6) {
|
||||||
.font(.body)
|
Text(row.displayName ?? row.accountId)
|
||||||
.lineLimit(1)
|
.font(.system(.body, design: .default).weight(.medium))
|
||||||
Text(subline)
|
.lineLimit(1)
|
||||||
.font(.caption)
|
.truncationMode(.middle)
|
||||||
.foregroundStyle(.secondary)
|
if row.paused {
|
||||||
.lineLimit(1)
|
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()
|
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 {
|
if row.paused {
|
||||||
Button("Resume") { viewModel.resume(row) }
|
Button("Resume") { viewModel.resume(row) }
|
||||||
} else {
|
} else {
|
||||||
@@ -98,24 +169,46 @@ struct BarRowView: View {
|
|||||||
}
|
}
|
||||||
Button("Set as default") { viewModel.setDefault(row) }
|
Button("Set as default") { viewModel.setDefault(row) }
|
||||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||||
|
Divider()
|
||||||
if let tier = row.tier {
|
if let tier = row.tier {
|
||||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||||
}
|
}
|
||||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
.menuStyle(.borderlessButton)
|
.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 {
|
private var healthColor: Color {
|
||||||
var parts: [String] = [row.provider]
|
switch row.health {
|
||||||
if let tier = row.tier { parts.append(tier) }
|
case "error": return .red
|
||||||
parts.append(BarFormatting.quotaLabel(row.quotaPercentage))
|
case "warning": return .orange
|
||||||
let cost = BarFormatting.costLabel(row.todayCost)
|
default: return .green
|
||||||
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} ")
|
|
||||||
|
/// 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
|
@MainActor
|
||||||
final class BarViewModel: ObservableObject {
|
final class BarViewModel: ObservableObject {
|
||||||
@Published var rows: [BarSummaryRow] = []
|
@Published var rows: [BarSummaryRow] = []
|
||||||
|
@Published var analytics: BarAnalytics?
|
||||||
@Published var offline = false
|
@Published var offline = false
|
||||||
@Published var lastError: String?
|
@Published var lastError: String?
|
||||||
@Published var isRefreshing = false
|
@Published var isRefreshing = false
|
||||||
|
@Published var iconStyle: BarIconStyle {
|
||||||
|
didSet { MenuBarIcon.saveStyle(iconStyle) }
|
||||||
|
}
|
||||||
|
|
||||||
private let home: String
|
private let home: String
|
||||||
private var client: CCSBarClient?
|
private var client: CCSBarClient?
|
||||||
@@ -18,9 +22,15 @@ final class BarViewModel: ObservableObject {
|
|||||||
|
|
||||||
init(home: String = NSHomeDirectory()) {
|
init(home: String = NSHomeDirectory()) {
|
||||||
self.home = home
|
self.home = home
|
||||||
|
self.iconStyle = MenuBarIcon.loadStyle()
|
||||||
reconnect()
|
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.
|
/// Compact status-bar title.
|
||||||
var statusTitle: String {
|
var statusTitle: String {
|
||||||
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
|
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
|
||||||
@@ -69,6 +79,12 @@ final class BarViewModel: ObservableObject {
|
|||||||
// offline state when there is nothing to show.
|
// offline state when there is nothing to show.
|
||||||
if rows.isEmpty { offline = true }
|
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
|
// MARK: Account actions
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ struct CCSBarApp: App {
|
|||||||
MenuBarExtra {
|
MenuBarExtra {
|
||||||
BarMenuView(viewModel: viewModel)
|
BarMenuView(viewModel: viewModel)
|
||||||
} label: {
|
} 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)
|
Text(viewModel.statusTitle)
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.window)
|
.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
|
||||||
|
}
|
||||||
@@ -199,6 +199,27 @@ do {
|
|||||||
}
|
}
|
||||||
recorder.status = 200
|
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
|
// cleanup
|
||||||
try? FileManager.default.removeItem(atPath: tmp)
|
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)
|
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)
|
/// Compact status-bar title. Shows the most-used (lowest remaining quota)
|
||||||
/// active account, plus today's total cost when available.
|
/// active account, plus today's total cost when available.
|
||||||
/// Example: "agy 82% · $3.20". Falls back to "CCS" when there are no rows.
|
/// 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)
|
// MARK: Account control (reuses existing CCS endpoints)
|
||||||
|
|
||||||
public func pause(provider: String, accountId: String) async throws {
|
public func pause(provider: String, accountId: String) async throws {
|
||||||
|
|||||||
Reference in new issue
Block a user