Files
ccs/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift
T
Tam Nhu Tran 0e0b912187 feat(bar-app): subscription-first cockpit with detailed quota card
Lead the dropdown with subscriptions: a dedicated card shows the binding
window as the hero (gauge, remaining %, reset countdown, and a burn-rate
'left at this pace' line), a secondary window, and the Opus/Sonnet split for
Max, with a stale marker for older Codex data. Demote spend/usage to a compact
strip and surface cross-tool headroom. Add pure burn-rate / time-to-exhaustion
and binding-window math in Core. Kill the menu scroll indicator for real
(NSScrollView hider) so content stays aligned. Spend-cap alerts default off
(pay-per-use, opt-in).
2026-06-09 14:40:03 -04:00

181 lines
5.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import CCSBarCore
/// Demoted spend strip + surface/model breakdown.
///
/// Spend is informational pool context, NEVER the headline — so the loud 2×2
/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses
/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin
/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and
/// subordinate, below the pool accounts.
///
/// When the trailing 30 days carry no spend the strip reads the honest idle line
/// ("No usage in N days") instead of three dead "$0.00" cells.
struct BarAnalyticsView: View {
let analytics: BarAnalytics
/// Which slot of the dropdown this instance renders. `.spend` is the thin strip
/// placed below the subscriptions cockpit; `.breakdown` is the by-surface /
/// top-models detail placed below the pool accounts.
enum Section { case spend, breakdown }
var section: Section = .spend
private var lastActive: String? {
BarFormatting.lastActiveLabel(
iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity)
}
var body: some View {
switch section {
case .spend:
spendStrip
case .breakdown:
breakdown
}
}
/// By-surface + top-models detail, tightened and subordinate.
@ViewBuilder private var breakdown: some View {
VStack(alignment: .leading, spacing: 6) {
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
// the backend supplies at least one surface entry. Top 5 keeps it compact.
if !analytics.bySurface.isEmpty {
SectionLabel("By surface")
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
ForEach(analytics.bySurface.prefix(5)) { surface in
SurfaceBar(surface: surface, peak: peakSurface)
}
}
// 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)
}
}
}
}
/// True when there's any surface/model detail worth a divider + section.
var hasBreakdown: Bool {
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
}
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
/// line, and a thin inline 30-day sparkline when there is real spend.
private var spendStrip: some View {
VStack(alignment: .leading, spacing: 3) {
SectionLabel("Spend")
if analytics.hasRecentData {
Text(spendCaption)
.font(.caption2)
.foregroundStyle(.secondary)
if !sparklineIsEmpty {
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 16)
}
} else {
Text(idleCaption)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
private var spendCaption: String {
"today \(BarFormatting.money(analytics.today.cost))"
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
}
/// Honest idle caption when there's no recent spend, folding in last-active.
private var idleCaption: String {
let headline =
analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days"
if let lastActive { return "\(headline) · \(lastActive.lowercased())" }
return headline
}
private var sparklineIsEmpty: Bool {
analytics.byDay.allSatisfy { $0.cost <= 0 }
}
}
/// One usage-surface row: surface name + proportional accent bar + cost and
/// request count. Mirrors ModelBar visually so the two sections feel cohesive.
private struct SurfaceBar: View {
let surface: BarAnalyticsSurface
let peak: Double
var body: some View {
GeometryReader { geo in
let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 5)
.fill(BarTheme.accent.opacity(0.16))
.frame(width: max(8, geo.size.width * fraction))
HStack {
Text(surface.surface)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
HStack(spacing: 4) {
Text(BarFormatting.count(surface.requests))
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
Text(BarFormatting.money(surface.cost))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 8)
}
}
.frame(height: 22)
}
}
/// 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, 1)
}
}