Files
ccs/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift
T
Tam Nhu Tran 6ed5b2d62b feat(bar): live Codex usage sync with force refresh and spend-chart periods
Codex quota in the menu bar came only from frozen local session logs, so it
showed stale data ("older session") that no refresh could update. Fetch it live
from the same source the dashboard uses, under the existing Claude-style safety
controls (10-min TTL, in-flight coalescing, 429 backoff, circuit breaker,
serve-stale), falling back to local logs when offline or rate-limited. A success
with no usable 5h/weekly window keeps the local reading rather than caching a
contentless row. The footer refresh and a new inline button on the Codex card
force a live pull past the open debounce, and a forced /summary re-pulls native
rows while serving the last-known cached rows if the live pull overruns its
budget, so the Claude/Codex cards never blank mid-refresh.

The spend strip gains a Today / 7d / 30d selector (default 7d), a taller chart,
and bottom time-axis labels (local hours for today, weekday for 7d, dates for
30d). The analytics endpoint now exposes a 24-bucket hourly series for today,
converted from the pipeline's UTC hour keys to the user's local clock so the
intra-day chart matches the dashboard.
2026-06-20 18:37:18 -04:00

341 lines
13 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 {
@Environment(\.barTheme) private var theme
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
/// Controls whether the spend sparkline renders as bars or a line graph.
/// Passed in by BarMenuView so it reflects the user's persisted choice live.
var spendChartStyle: SpendChartStyle = .bars
/// Inline flip for the spend chart style, surfaced as a small toggle in the
/// Spend header (using the otherwise-blank space) so the user switches
/// bars/line in place rather than digging into Settings. nil for `.breakdown`.
var onToggleSpendStyle: (() -> Void)? = nil
/// Selected time window for the spend chart. Default .last7d so the rolling
/// 7-day view is the startup default, matching SpendPeriodStore's default.
var spendPeriod: SpendPeriod = .last7d
/// Callback when the user taps a period selector button. nil for .breakdown.
var onSelectPeriod: ((SpendPeriod) -> Void)? = nil
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: 8) {
// 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 informational spend strip: a "SPEND" label, period selector,
/// bars/line toggle, a muted caption, a taller sparkline, and axis labels.
private var spendStrip: some View {
VStack(alignment: .leading, spacing: 5) {
// Header row: section label | period selector | bars/line toggle.
HStack(spacing: 6) {
SectionLabel("Spend")
Spacer()
// Compact 3-segment period selector — only when there is data to chart,
// so the idle state shows no controls that would have no visible effect.
if let select = onSelectPeriod, analytics.hasRecentData {
periodSelector(onSelect: select)
}
// Inline bars/line toggle — only when there is data to render.
if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty {
Button(action: toggle) {
Image(
systemName: spendChartStyle == .bars
? "chart.line.uptrend.xyaxis" : "chart.bar.fill"
)
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.foregroundStyle(.tertiary)
.help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")")
}
}
if analytics.hasRecentData {
Text(spendCaption)
.font(.caption2)
.foregroundStyle(.secondary)
// height: 56 — more room so per-hour or per-day gradations are readable.
Sparkline(values: activeSeries, accent: theme.accent, style: spendChartStyle)
.frame(height: 56)
// Axis labels below the chart, aligned to the same width.
axisLabelRow
} else {
Text(idleCaption)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// Small 3-button period selector: "Today / 7d / 30d".
private func periodSelector(onSelect: @escaping (SpendPeriod) -> Void) -> some View {
HStack(spacing: 4) {
periodButton("Today", period: .today, onSelect: onSelect)
periodButton("7d", period: .last7d, onSelect: onSelect)
periodButton("30d", period: .last30d, onSelect: onSelect)
}
}
private func periodButton(
_ label: String, period: SpendPeriod, onSelect: @escaping (SpendPeriod) -> Void
) -> some View {
Button(label) { onSelect(period) }
.buttonStyle(.borderless)
.font(
spendPeriod == period
? .system(size: 10, weight: .semibold)
: .system(size: 10))
.foregroundStyle(spendPeriod == period ? theme.accent : Color.secondary)
}
/// The value series for the currently-selected period.
private var activeSeries: [Double] {
switch spendPeriod {
case .today:
return analytics.byHour.map(\.cost)
case .last7d:
return Array(analytics.byDay.suffix(7)).map(\.cost)
case .last30d:
return analytics.byDay.map(\.cost)
}
}
/// Caption showing the cost for the active period. Each period sums the SAME
/// series it charts so the caption total always matches the visible bars.
private var spendCaption: String {
switch spendPeriod {
case .today:
// Sum byHour (the charted series) so caption == bars. Fall back to the
// daily today total only when there is no hourly series to draw.
let cost =
analytics.byHour.isEmpty
? analytics.today.cost
: analytics.byHour.reduce(0) { $0 + $1.cost }
return "today \(BarFormatting.money(cost))"
case .last7d:
let cost = Array(analytics.byDay.suffix(7)).reduce(0) { $0 + $1.cost }
return "7d \(BarFormatting.money(cost))"
case .last30d:
return "30d \(BarFormatting.money(analytics.last30d.cost))"
}
}
/// Axis labels rendered below the sparkline. Each tick is placed at its data
/// point's horizontal fraction (bar-center) so the label sits under the hour /
/// day it names — not merely evenly distributed, which drifts on the Today
/// view where the tick indices aren't at even fractions of the width. Edge
/// labels are clamped inward by their estimated half-width so they don't clip.
@ViewBuilder private var axisLabelRow: some View {
let ticks = axisTicks(for: spendPeriod)
if !ticks.isEmpty {
GeometryReader { geo in
let width = Double(geo.size.width)
ForEach(Array(ticks.enumerated()), id: \.offset) { _, tick in
// ~2.75pt per char at 9pt monospaced is half a glyph; clamp keeps the
// first/last labels fully on-screen.
let halfW = max(8.0, Double(tick.label.count) * 2.75)
let x = min(max(tick.fraction * width, halfW), max(halfW, width - halfW))
Text(tick.label)
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize()
.position(x: x, y: 6)
}
}
.frame(height: 12)
}
}
/// Tick (label, horizontal fraction 0...1) pairs for the current period.
/// Fraction is the bar CENTER ((i + 0.5) / count) so labels align under the
/// default bar chart; for the line style the end ticks differ by half a bar,
/// which is visually negligible.
private func axisTicks(for period: SpendPeriod) -> [(label: String, fraction: Double)] {
func center(_ i: Int, _ n: Int) -> Double { n > 0 ? (Double(i) + 0.5) / Double(n) : 0 }
switch period {
case .today:
// Hours at 0, 6, 12, 18, 23 — only those that exist.
let hours = analytics.byHour
guard !hours.isEmpty else { return [] }
let n = hours.count
return [0, 6, 12, 18, 23].compactMap { idx -> (String, Double)? in
guard idx < n, let label = BarCardFormatting.hourShort(fromHourKey: hours[idx].hour)
else { return nil }
return (label, center(idx, n))
}
case .last7d:
// All 7 days (or whatever suffix(7) yields): short weekday "Mon".
let days = Array(analytics.byDay.suffix(7))
guard !days.isEmpty else { return [] }
let n = days.count
return days.enumerated().compactMap { (i, d) -> (String, Double)? in
guard let label = BarCardFormatting.weekdayShort(fromDayKey: d.date) else { return nil }
return (label, center(i, n))
}
case .last30d:
// 5 evenly-spaced "MMM d" labels: first, ~1/4, mid, ~3/4, last.
let days = analytics.byDay
guard days.count >= 2 else { return [] }
let n = days.count
let last = n - 1
let indices = [0, last / 4, last / 2, last * 3 / 4, last].reduce(into: [Int]()) { acc, i in
if acc.last != i { acc.append(i) }
}
return indices.compactMap { i -> (String, Double)? in
guard i < n, let label = BarCardFormatting.monthDayShort(fromDayKey: days[i].date)
else { return nil }
return (label, center(i, n))
}
}
}
/// 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
}
/// True when the active period's series is all zero. Still shows the chart
/// frame + axis (do not hide them), but the bars/line toggle is suppressed.
private var sparklineIsEmpty: Bool {
activeSeries.allSatisfy { $0 <= 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 {
@Environment(\.barTheme) private var theme
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(theme.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, 10)
}
}
.frame(height: 26)
}
}
/// One top-model row: name + spend with a proportional accent bar behind.
private struct ModelBar: View {
@Environment(\.barTheme) private var theme
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(theme.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, 10)
}
}
.frame(height: 26)
}
}
/// 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: 11, weight: .bold))
.foregroundStyle(.secondary)
.padding(.top, 1)
}
}