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.
This commit is contained in:
Tam Nhu Tran committed 2026-06-20 18:37:18 -04:00
1 parent 9977c49fab
commit 6ed5b2d62b
13 files changed
+1263 -53

No files matched your search

@@ -26,6 +26,11 @@ struct BarAnalyticsView: View {
/// 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(
@@ -71,14 +76,20 @@ struct BarAnalyticsView: View {
!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.
/// 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()
// Inline bars/line toggle in the header's blank space — no Settings trip.
// 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(
@@ -92,16 +103,16 @@ struct BarAnalyticsView: View {
.help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")")
}
}
if analytics.hasRecentData {
Text(spendCaption)
.font(.caption2)
.foregroundStyle(.secondary)
if !sparklineIsEmpty {
// height: 30 (up from 18) so daily spend gradations are clearly readable.
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent,
style: spendChartStyle)
.frame(height: 30)
}
// 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)
@@ -110,11 +121,129 @@ struct BarAnalyticsView: View {
}
}
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
/// 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 {
"today \(BarFormatting.money(analytics.today.cost))"
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
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.
@@ -125,8 +254,10 @@ struct BarAnalyticsView: View {
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 {
analytics.byDay.allSatisfy { $0.cost <= 0 }
activeSeries.allSatisfy { $0 <= 0 }
}
}
@@ -51,4 +51,57 @@ enum BarCardFormatting {
fmt.dateFormat = "HH:mm"
return fmt.string(from: date)
}
// MARK: - Axis label formatters (used by BarAnalyticsView spend chart)
/// Short hour label from a byHour key "YYYY-MM-DD HH:00", e.g. "12a", "6p".
/// Extracts the HH part (characters at index 11-12) and converts to 12-hour
/// format with lowercase "a"/"p" suffix. Returns nil for unparseable keys.
static func hourShort(fromHourKey key: String) -> String? {
// key format: "YYYY-MM-DD HH:00" — HH is at offset 11, length 2.
guard key.count >= 13 else { return nil }
let start = key.index(key.startIndex, offsetBy: 11)
let end = key.index(start, offsetBy: 2)
guard let hour = Int(key[start..<end]) else { return nil }
switch hour {
case 0: return "12a"
case 1..<12: return "\(hour)a"
case 12: return "12p"
default: return "\(hour - 12)p"
}
}
/// Short weekday label from a byDay key "YYYY-MM-DD", e.g. "Mon".
/// Returns nil for unparseable keys. Formats in UTC to match how `dayDate`
/// parses the key, so the weekday names the calendar day the key represents
/// (formatting in local time would shift it a day for users west of UTC).
static func weekdayShort(fromDayKey key: String) -> String? {
guard let date = dayDate(fromKey: key) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "EEE"
return fmt.string(from: date)
}
/// Short month+day label from a byDay key "YYYY-MM-DD", e.g. "Jun 5".
/// Returns nil for unparseable keys. Formats in UTC to match `dayDate`'s
/// parse zone so the label names the same calendar day as the key.
static func monthDayShort(fromDayKey key: String) -> String? {
guard let date = dayDate(fromKey: key) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "MMM d"
return fmt.string(from: date)
}
/// Parse a "YYYY-MM-DD" key into a Date at midnight UTC.
private static func dayDate(fromKey key: String) -> Date? {
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: key)
}
}
@@ -92,8 +92,8 @@ struct BarMenuView: View {
accountsSection
// (3) SPEND — demoted to a thin informational strip below the cockpit.
// spendChartStyle is threaded from the viewModel and toggled inline
// from the Spend header, so a change updates the chart immediately.
// spendChartStyle and spendPeriod are threaded from the viewModel and
// toggled/selected inline from the Spend header so changes are live.
if let analytics = viewModel.analytics {
Divider()
BarAnalyticsView(
@@ -102,7 +102,9 @@ struct BarMenuView: View {
onToggleSpendStyle: {
viewModel.spendChartStyle =
viewModel.spendChartStyle == .bars ? .line : .bars
})
},
spendPeriod: viewModel.spendPeriod,
onSelectPeriod: { viewModel.spendPeriod = $0 })
}
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
@@ -185,7 +187,7 @@ struct BarMenuView: View {
} else {
subscriptionsHeader(parts.subscriptions)
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
BarSubscriptionCard(row: row)
BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() })
}
}
}
@@ -341,7 +343,7 @@ struct BarMenuView: View {
.help("Settings — appearance/theme, menu-bar glance, and alerts")
Spacer()
Button {
viewModel.onOpen()
viewModel.forceRefresh()
} label: {
Image(systemName: "arrow.clockwise")
}
@@ -16,6 +16,9 @@ struct BarSubscriptionCard: View {
/// Injected clock — defaults to live Date() in production, pinned in previews
/// and tests so countdown math is deterministic.
var now: Date = Date()
/// Optional force-refresh action. When provided, the stale footnote appends a
/// compact inline refresh button so the user can reload without reopening menus.
var onRefresh: (() -> Void)? = nil
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
@@ -219,6 +222,8 @@ struct BarSubscriptionCard: View {
/// "as of HH:mm (older session)" caption when the Codex reading came from an
/// older session. The bar still renders — the data is real, just not live.
/// When `onRefresh` is provided, a compact inline refresh button trails the
/// caption so the user can force-reload without extra navigation.
@ViewBuilder private var staleFootnote: some View {
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
HStack(spacing: 4) {
@@ -228,6 +233,15 @@ struct BarSubscriptionCard: View {
Text("as of \(clock), older session")
.font(.caption2)
.foregroundStyle(.tertiary)
if let refresh = onRefresh {
Button(action: refresh) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 9))
}
.buttonStyle(.borderless)
.foregroundStyle(.tertiary)
.help("Force refresh to get the latest data")
}
}
}
}
@@ -33,6 +33,11 @@ final class BarViewModel: ObservableObject {
@Published var spendChartStyle: SpendChartStyle {
didSet { SpendChartStyleStore.save(spendChartStyle) }
}
/// Active time window for the spend sparkline (today/7d/30d). Persisted via
/// SpendPeriodStore; didSet mirrors the spendChartStyle pattern.
@Published var spendPeriod: SpendPeriod {
didSet { SpendPeriodStore.save(spendPeriod) }
}
/// The alerts the most recent evaluation wanted delivered, surfaced in the
/// dropdown so users who deny notifications still see the conditions.
@Published var activeAlerts: [BarNotification] = []
@@ -71,6 +76,7 @@ final class BarViewModel: ObservableObject {
self.appearance = BarAppearanceStore.load()
self.glanceMode = prefs.load().glanceMode
self.spendChartStyle = SpendChartStyleStore.load()
self.spendPeriod = SpendPeriodStore.load()
reconnect()
startBackgroundPolling()
}
@@ -219,6 +225,16 @@ final class BarViewModel: ObservableObject {
reconnectAndLoad(force: force)
}
// MARK: - Force refresh (bypasses the 15s debounce)
/// Unconditional force-refresh: skips the debouncer and calls
/// `reconnectAndLoad(force: true)` directly. Used by the footer Refresh button
/// and by the Codex stale-footnote inline action so those never silently no-op
/// inside the debounce window.
func forceRefresh() {
reconnectAndLoad(force: true)
}
// MARK: - Start CCS (called from offline UI button)
/// Trigger a full connect sequence (probe → launch → poll). Used by the
@@ -57,6 +57,20 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
}
}
/// Spend and request count for one hour of today. 24 entries, zero-filled,
/// oldest (00:00) to newest (23:00). The `hour` key is "YYYY-MM-DD HH:00".
public struct Hour: Codable, Sendable, Equatable, Identifiable {
public let hour: String
public let cost: Double
public let requests: Int
public var id: String { hour }
public init(hour: String, cost: Double, requests: Int) {
self.hour = hour
self.cost = cost
self.requests = requests
}
}
public let today: Window
public let last7d: Window
public let last30d: Window
@@ -67,6 +81,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
public let allTime: Window
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
public let byDay: [Day]
/// Today's hourly breakdown: 24 entries, oldest (00:00) → newest (23:00),
/// zero-filled. Absent from older payloads; defaults to [].
public let byHour: [Hour]
public let topModels: [Model]
/// "30d" when recent data exists, else "all".
public let topModelsWindow: String
@@ -90,6 +107,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
monthToDate: Window = Window(cost: 0, requests: 0),
allTime: Window,
byDay: [Day],
byHour: [Hour] = [],
topModels: [Model],
topModelsWindow: String,
lastActivityAt: String? = nil,
@@ -104,6 +122,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
self.monthToDate = monthToDate
self.allTime = allTime
self.byDay = byDay
self.byHour = byHour
self.topModels = topModels
self.topModelsWindow = topModelsWindow
self.lastActivityAt = lastActivityAt
@@ -127,6 +146,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
(try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0)
allTime = try c.decode(Window.self, forKey: .allTime)
byDay = try c.decode([Day].self, forKey: .byDay)
// `byHour` is a new field; older payloads omit it. Default to [] so cached
// or older-backend payloads decode cleanly without throwing.
byHour = (try c.decodeIfPresent([Hour].self, forKey: .byHour)) ?? []
topModels = try c.decode([Model].self, forKey: .topModels)
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
@@ -33,3 +33,32 @@ public enum SpendChartStyleStore {
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
}
}
// MARK: - SpendPeriod
/// Time window for the spend sparkline selector: today (hourly), last 7 days,
/// or last 30 days. Mirrors the SpendChartStyle pattern: CaseIterable + Sendable.
public enum SpendPeriod: String, CaseIterable, Sendable {
case today
case last7d
case last30d
}
// MARK: - SpendPeriodStore
/// Persists the chosen spend period. Mirrors SpendChartStyleStore exactly:
/// a UserDefaults key, a static load, and a static save. The `?? .last7d`
/// fallback is the sole source of the default.
public enum SpendPeriodStore {
public static let defaultsKey = "ccsbar.spendPeriod"
public static func load() -> SpendPeriod {
let raw = UserDefaults.standard.string(forKey: defaultsKey)
?? SpendPeriod.last7d.rawValue
return SpendPeriod(rawValue: raw) ?? .last7d
}
public static func save(_ period: SpendPeriod) {
UserDefaults.standard.set(period.rawValue, forKey: defaultsKey)
}
}