From 779a58cf711374799edffeacb043f2faf25c60aa Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 18 Jun 2026 09:20:13 -0400 Subject: [PATCH 1/6] =?UTF-8?q?Live=20monitor:=20stop=20ingesting=20per=20?= =?UTF-8?q?poll=20(3=E2=80=934s);=20use=20the=20background=20watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root of the slow "loading": each live poll ran a one-shot `burn ingest`, which is ~3–5s on a 591MB ledger (even with nothing new). Read-only `burn summary` is only ~10ms. Move freshness back to a continuous background `burn ingest --watch` (FS-event driven, ~1s poll, incremental — verified it catches new turns), and keep the poll path to summary only. Poll cadence back to 1.5s. (Keeps the moving-average rate fix from the prior commit.) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/macos/Sources/Burn/BurnLedger.swift | 41 +++++++++++++++---- .../Sources/Burn/LiveBurnViewModel.swift | 20 ++++----- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/apps/macos/Sources/Burn/BurnLedger.swift b/apps/macos/Sources/Burn/BurnLedger.swift index f849d124..cc812c4b 100644 --- a/apps/macos/Sources/Burn/BurnLedger.swift +++ b/apps/macos/Sources/Burn/BurnLedger.swift @@ -77,15 +77,38 @@ actor BurnLedger { return Summary(cost: cost, tokens: tokens) } - // MARK: - Ingest - - /// Runs one incremental `burn ingest` sweep so the ledger reflects freshly - /// written turns. `burn summary` is read-only (it no longer ingests), and the - /// background `ingest --watch` doesn't keep up, so the live monitor calls this - /// each poll to keep the numbers moving. A warm incremental sweep is fast - /// (only new turns). No-op / `nil` when burn is unavailable. - func ingest() { - _ = runBurn(["ingest", "--quiet"]) + // MARK: - Long-lived ingest watch + + /// The running `burn ingest --watch` process, if any. `burn summary` is + /// read-only (~10ms) but a one-shot `burn ingest` sweep is multi-second on a + /// large ledger — far too slow to run per poll. Instead this long-lived watch + /// keeps the ledger fresh incrementally (FS-event driven, ~1s poll), so the + /// live view's summary polls stay fast. + private var watchProcess: Process? + + /// Starts a background `burn ingest --watch` if one isn't already running. + /// Only runs with the bundled native helper (a login-shell child can't be + /// cleanly managed); the live chart still polls either way. + func startIngestWatch() { + guard watchProcess == nil else { return } + guard case .bundled(let url) = resolveTool() else { return } + let process = Process() + process.executableURL = url + process.arguments = ["ingest", "--watch", "--quiet"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + watchProcess = process + } catch { + watchProcess = nil + } + } + + /// Terminates the background watch process, if running. + func stopIngestWatch() { + watchProcess?.terminate() + watchProcess = nil } // MARK: - Resolution & invocation diff --git a/apps/macos/Sources/Burn/LiveBurnViewModel.swift b/apps/macos/Sources/Burn/LiveBurnViewModel.swift index ae2c8b2c..57049b7e 100644 --- a/apps/macos/Sources/Burn/LiveBurnViewModel.swift +++ b/apps/macos/Sources/Burn/LiveBurnViewModel.swift @@ -31,11 +31,9 @@ final class LiveBurnViewModel: ObservableObject { /// True once we've confirmed burn can't be queried, so the view can hint. @Published private(set) var unavailable = false - /// How often we ingest + poll `burn summary`. Each cycle runs a one-shot - /// incremental ingest (summary is read-only and the background watch lags), - /// which takes a beat on a large ledger — so the cadence is a few seconds, - /// not sub-second. - private let pollInterval: TimeInterval = 2.5 + /// How often we poll `burn summary` (read-only, ~10ms). Freshness comes from + /// the background `ingest --watch`, so this can be a tight, smooth cadence. + private let pollInterval: TimeInterval = 1.5 /// Trailing window the burn rate is averaged over. Each poll re-queries the /// last `rateWindow` seconds and divides — a moving average that's robust to /// the window sliding and to late ingests, unlike an inter-sample delta @@ -56,19 +54,22 @@ final class LiveBurnViewModel: ObservableObject { self.provider = provider } - /// Begins the ingest+poll loop. Idempotent. + /// Begins the poll loop and the background ingest watch. Idempotent. func start() { guard timer == nil else { return } + Task { await BurnLedger.shared.startIngestWatch() } Task { await poll() } timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in Task { await self?.poll() } } } - /// Stops the loop. Called when the live view disappears or the app closes. + /// Stops the loop and the watch. Called when the live view disappears or the + /// app closes. func stop() { timer?.invalidate() timer = nil + Task { await BurnLedger.shared.stopIngestWatch() } } /// Switches the tracked provider, clearing the series so the new provider's @@ -88,11 +89,6 @@ final class LiveBurnViewModel: ObservableObject { polling = true defer { polling = false } - // Freshen the ledger ourselves — summary is read-only and the background - // watch lags, so without this the totals don't move even with active - // sessions. - await BurnLedger.shared.ingest() - let burnProvider = BurnLedger.burnProvider(for: provider) let now = Date() guard let window = await BurnLedger.shared.summary( From 2ece6483cdc347485b259d528d40d58c3ab848b8 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 18 Jun 2026 09:26:04 -0400 Subject: [PATCH 2/6] Live tab: overlay both providers with toggles; Codex blue/purple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LiveBurnViewModel now polls every provider each cycle and keeps a per-provider sample series; exposes show/hide toggles (won't hide the last one). - LiveBurnView overlays one color-coded line per provider (Claude coral, Codex blue/purple) on both the rate and cumulative charts, with toggle chips that double as the legend. Headline is the combined rate/spend across shown providers. Independent of the Usage tab's single-select provider. - Codex brandColor → blue/purple (#5B6CFF) to match its icon's color scheme, replacing the green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/macos/Sources/Burn/BrandIcon.swift | 2 +- apps/macos/Sources/Burn/ContentView.swift | 9 +- apps/macos/Sources/Burn/LiveBurnView.swift | 175 ++++++++++-------- .../Sources/Burn/LiveBurnViewModel.swift | 137 +++++++------- 4 files changed, 164 insertions(+), 159 deletions(-) diff --git a/apps/macos/Sources/Burn/BrandIcon.swift b/apps/macos/Sources/Burn/BrandIcon.swift index 2639b913..b3a06181 100644 --- a/apps/macos/Sources/Burn/BrandIcon.swift +++ b/apps/macos/Sources/Burn/BrandIcon.swift @@ -7,7 +7,7 @@ extension ProviderName { var brandColor: Color { switch self { case .claude: return Color(red: 0.85, green: 0.47, blue: 0.34) // Claude coral #D97757 - case .codex: return Color(red: 0.06, green: 0.64, blue: 0.50) // OpenAI green #10A37F + case .codex: return Color(red: 0.36, green: 0.42, blue: 1.0) // Codex blue/purple #5B6CFF } } diff --git a/apps/macos/Sources/Burn/ContentView.swift b/apps/macos/Sources/Burn/ContentView.swift index 18d66391..6dbf6d7c 100644 --- a/apps/macos/Sources/Burn/ContentView.swift +++ b/apps/macos/Sources/Burn/ContentView.swift @@ -30,8 +30,9 @@ struct ContentView: View { init(viewModel: UsageViewModel) { self.viewModel = viewModel - _liveViewModel = StateObject( - wrappedValue: LiveBurnViewModel(provider: viewModel.selectedProvider)) + // The live view shows all providers at once (its own per-provider + // toggles), independent of the Usage tab's single-select provider. + _liveViewModel = StateObject(wrappedValue: LiveBurnViewModel()) } var body: some View { @@ -52,10 +53,6 @@ struct ContentView: View { .padding(16) .frame(width: 380) .background(quitShortcut) - // Keep the live stream tracking whichever provider the picker selects. - .onChange(of: viewModel.selectedProvider) { provider in - liveViewModel.select(provider) - } } /// Segmented switch between the usage burndown and the live stream. Lives in diff --git a/apps/macos/Sources/Burn/LiveBurnView.swift b/apps/macos/Sources/Burn/LiveBurnView.swift index 22b352bc..ca3631ca 100644 --- a/apps/macos/Sources/Burn/LiveBurnView.swift +++ b/apps/macos/Sources/Burn/LiveBurnView.swift @@ -2,44 +2,50 @@ import SwiftUI import Charts /// The "Burn rate" tab: a moving, streaming chart of token burn that updates in -/// real time. The headline is the per-interval burn rate (tokens/sec) — a moving -/// rate is the compelling read — with a secondary cumulative-tokens line. +/// real time, with one color-coded line per provider (Claude, Codex) overlaid +/// and per-provider show/hide toggles. The headline is the combined per-interval +/// burn rate (tokens/sec) across the shown providers, with a cumulative line. /// /// Owns a `LiveBurnViewModel` whose lifecycle (poll timer + background ingest -/// watch) is bound to this view's appearance: started in `onAppear`, torn down -/// in `onDisappear`. Falls back to a hint when burn can't be queried, mirroring -/// how the rest of the app no-ops on a missing binary. +/// watch) is bound to this view's appearance. Falls back to a hint when burn +/// can't be queried, mirroring how the rest of the app no-ops on a missing +/// binary. struct LiveBurnView: View { @ObservedObject var viewModel: LiveBurnViewModel - private let rateColor = Color.orange - private let cumulativeColor = Color.blue + /// Providers currently shown, in a stable order. + private var shown: [ProviderName] { ProviderName.allCases.filter(viewModel.isEnabled) } var body: some View { VStack(alignment: .leading, spacing: 12) { if viewModel.unavailable { hint - } else if viewModel.samples.count < 2 { + } else if !hasData { warming } else { headline rateChart.frame(height: 130) cumulativeChart.frame(height: 90) } + providerToggles } .frame(maxWidth: .infinity, alignment: .leading) .onAppear { viewModel.start() } .onDisappear { viewModel.stop() } } - // MARK: Headline + private var hasData: Bool { + shown.contains { (viewModel.series[$0]?.count ?? 0) >= 2 } + } + + // MARK: Headline (combined across shown providers) private var headline: some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 1) { Text("Burn rate") .font(.title3.weight(.bold)) - Text("live · trailing 5 min") + Text("live · combined") .font(.caption2) .foregroundStyle(.tertiary) } @@ -47,7 +53,6 @@ struct LiveBurnView: View { VStack(alignment: .trailing, spacing: 1) { Text(rateLabel) .font(.headline.weight(.bold)) - .foregroundStyle(rateColor) .monospacedDigit() Text(spendLabel) .font(.caption2) @@ -57,97 +62,81 @@ struct LiveBurnView: View { } } - private var latest: LiveBurnSample? { viewModel.samples.last } - - private var rateLabel: String { - let rate = latest?.tokensPerSecond ?? 0 - if rate >= 1000 { - return String(format: "%.1fk tok/s", rate / 1000) - } - return "\(Int(rate.rounded())) tok/s" + private var totalRate: Double { + shown.reduce(0) { $0 + (viewModel.series[$1]?.last?.tokensPerSecond ?? 0) } } - - private var spendLabel: String { - let perMin = latest?.dollarsPerMinute ?? 0 - return String(format: "$%.2f/min", perMin) + private var totalSpend: Double { + shown.reduce(0) { $0 + (viewModel.series[$1]?.last?.dollarsPerMinute ?? 0) } + } + private var rateLabel: String { + totalRate >= 1000 ? String(format: "%.1fk tok/s", totalRate / 1000) + : "\(Int(totalRate.rounded())) tok/s" } + private var spendLabel: String { String(format: "$%.2f/min", totalSpend) } // MARK: Charts - /// The moving burn-rate line (tokens/sec per polled interval). private var rateChart: some View { - Chart(viewModel.samples) { sample in - AreaMark( - x: .value("Time", sample.date), - y: .value("Tokens/s", sample.tokensPerSecond) - ) - .foregroundStyle( - .linearGradient( - colors: [rateColor.opacity(0.28), rateColor.opacity(0.02)], - startPoint: .top, - endPoint: .bottom - ) - ) - .interpolationMethod(.monotone) - - LineMark( - x: .value("Time", sample.date), - y: .value("Tokens/s", sample.tokensPerSecond) - ) - .foregroundStyle(rateColor) - .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) - .interpolationMethod(.monotone) - } - .chartXScale(domain: xDomain) - .chartXAxis(.hidden) - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisGridLine().foregroundStyle(Color.primary.opacity(0.08)) - AxisValueLabel { - if let v = value.as(Double.self) { - Text(tokenAxisLabel(v)) - .font(.caption2) - .foregroundStyle(.tertiary) - } + Chart { + ForEach(shown, id: \.self) { provider in + ForEach(viewModel.series[provider] ?? []) { sample in + LineMark( + x: .value("Time", sample.date), + y: .value("Tokens/s", sample.tokensPerSecond), + series: .value("Provider", provider.displayName) + ) + .foregroundStyle(by: .value("Provider", provider.displayName)) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .interpolationMethod(.monotone) } } } + .chartForegroundStyleScale(domain: shown.map(\.displayName), range: shown.map(\.brandColor)) + .chartLegend(.hidden) // the toggles serve as the legend + .chartXScale(domain: xDomain) + .chartXAxis(.hidden) + .chartYAxis { tokenAxis(desiredCount: nil) } .chartCard() } - /// Cumulative tokens over the rolling window — a slower-moving companion. private var cumulativeChart: some View { - Chart(viewModel.samples) { sample in - LineMark( - x: .value("Time", sample.date), - y: .value("Tokens", sample.tokens) - ) - .foregroundStyle(cumulativeColor) - .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) - .interpolationMethod(.monotone) + Chart { + ForEach(shown, id: \.self) { provider in + ForEach(viewModel.series[provider] ?? []) { sample in + LineMark( + x: .value("Time", sample.date), + y: .value("Tokens", sample.tokens), + series: .value("Provider", provider.displayName) + ) + .foregroundStyle(by: .value("Provider", provider.displayName)) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .interpolationMethod(.monotone) + } + } } + .chartForegroundStyleScale(domain: shown.map(\.displayName), range: shown.map(\.brandColor)) + .chartLegend(.hidden) .chartXScale(domain: xDomain) .chartXAxis(.hidden) - .chartYAxis { - AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { value in - AxisGridLine().foregroundStyle(Color.primary.opacity(0.08)) - AxisValueLabel { - if let v = value.as(Double.self) { - Text(tokenAxisLabel(v)) - .font(.caption2) - .foregroundStyle(.tertiary) - } + .chartYAxis { tokenAxis(desiredCount: 3) } + .chartCard() + } + + private func tokenAxis(desiredCount: Int?) -> some AxisContent { + AxisMarks(position: .leading, values: desiredCount.map { .automatic(desiredCount: $0) } ?? .automatic) { value in + AxisGridLine().foregroundStyle(Color.primary.opacity(0.08)) + AxisValueLabel { + if let v = value.as(Double.self) { + Text(tokenAxisLabel(v)).font(.caption2).foregroundStyle(.tertiary) } } } - .chartCard() } - /// Always span the full sample window so the line visibly slides left as new - /// samples arrive, even when the buffer isn't full yet. + /// Span the union of all shown series so lines slide left together. private var xDomain: ClosedRange { - let dates = viewModel.samples.map(\.date) - guard let first = dates.first, let last = dates.last, first < last else { + let dates = shown.flatMap { viewModel.series[$0] ?? [] }.map(\.date) + guard let first = dates.min(), let last = dates.max(), first < last else { let now = Date() return now.addingTimeInterval(-1)...now } @@ -160,6 +149,32 @@ struct LiveBurnView: View { return "\(Int(value))" } + // MARK: Provider toggles (also the legend) + + private var providerToggles: some View { + HStack(spacing: 8) { + ForEach(ProviderName.allCases) { provider in + let on = viewModel.isEnabled(provider) + Button { viewModel.toggle(provider) } label: { + HStack(spacing: 5) { + Circle().fill(provider.brandColor).frame(width: 8, height: 8) + Text(provider.displayName).font(.caption) + } + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + Capsule().fill(on ? provider.brandColor.opacity(0.16) : Color.primary.opacity(0.05)) + ) + .opacity(on ? 1 : 0.45) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help("\(on ? "Hide" : "Show") \(provider.displayName)") + } + Spacer() + } + } + // MARK: Empty / warming states private var warming: some View { diff --git a/apps/macos/Sources/Burn/LiveBurnViewModel.swift b/apps/macos/Sources/Burn/LiveBurnViewModel.swift index 57049b7e..c239cca5 100644 --- a/apps/macos/Sources/Burn/LiveBurnViewModel.swift +++ b/apps/macos/Sources/Burn/LiveBurnViewModel.swift @@ -1,58 +1,47 @@ import SwiftUI -/// One polled reading of the live burn series: the cumulative totals at `date` -/// and the per-interval deltas ("burn rate") since the previous sample. +/// One polled reading of the live burn series for a provider: the running +/// session totals and the moving per-interval burn rate. struct LiveBurnSample: Identifiable { let id = UUID() let date: Date - /// Cumulative USD cost over the rolling window at this instant. + /// Running session cost (USD) — integral of the rate. let cost: Double - /// Cumulative token count over the rolling window at this instant. + /// Running session token count — integral of the rate. let tokens: Int - /// Tokens burned per second since the previous sample (0 for the first). + /// Moving-average tokens burned per second over the trailing rate window. let tokensPerSecond: Double - /// USD burned per minute since the previous sample (0 for the first). + /// Moving-average USD burned per minute over the trailing rate window. let dollarsPerMinute: Double } -/// Drives the live "Burn rate" tab. Polls `burn summary --json` on a short timer -/// against a rolling window, keeps a bounded in-memory ring buffer of samples, -/// and derives a moving per-interval burn rate. Spawns a long-lived -/// `burn ingest --watch` for the lifetime of the view so the polled numbers -/// actually move (`summary` only queries the ledger; it no longer freshens it). -/// -/// Mirrors `UsageViewModel`'s `@MainActor` + `Task`/timer style and the -/// graceful no-op-on-failure behavior — when burn is missing the series simply -/// stays empty and the view shows a hint. +/// Drives the live "Burn rate" tab. Polls `burn summary --json` for every +/// provider on a short timer and keeps a bounded per-provider ring buffer, so +/// the view can overlay one color-coded line per provider with show/hide +/// toggles. Freshness comes from a background `burn ingest --watch` (summary is +/// read-only); when burn is missing the series stay empty and the view hints. @MainActor final class LiveBurnViewModel: ObservableObject { - /// The rolling series of samples (oldest first), capped at `maxSamples`. - @Published private(set) var samples: [LiveBurnSample] = [] - /// True once we've confirmed burn can't be queried, so the view can hint. + /// Per-provider rolling sample series (oldest first), capped at `maxSamples`. + @Published private(set) var series: [ProviderName: [LiveBurnSample]] = [:] + /// Providers whose line is currently shown (the toggles). + @Published private(set) var enabled: Set = Set(ProviderName.allCases) + /// True once we've confirmed burn can't be queried at all. @Published private(set) var unavailable = false - /// How often we poll `burn summary` (read-only, ~10ms). Freshness comes from - /// the background `ingest --watch`, so this can be a tight, smooth cadence. + /// Poll `burn summary` (read-only, ~10ms). Freshness is handled by the watch. private let pollInterval: TimeInterval = 1.5 - /// Trailing window the burn rate is averaged over. Each poll re-queries the - /// last `rateWindow` seconds and divides — a moving average that's robust to - /// the window sliding and to late ingests, unlike an inter-sample delta - /// (which dips negative as old turns age out and reads as "no usage"). + /// Trailing window the burn rate is averaged over — robust to window slide + /// and late ingests (unlike an inter-sample delta, which dips negative). private let rateWindow: TimeInterval = 60 - /// Ring-buffer cap on samples kept on screen. + /// Ring-buffer cap on samples kept on screen, per provider. private let maxSamples = 150 - private var provider: ProviderName + private let providers = ProviderName.allCases private var timer: Timer? - /// Guards against overlapping polls if a `summary` call runs long. private var polling = false - /// Running session totals (integral of the rate) for the cumulative line. - private var sessionTokens = 0.0 - private var sessionCost = 0.0 - - init(provider: ProviderName) { - self.provider = provider - } + /// Running session totals (integral of the rate) per provider. + private var session: [ProviderName: (tokens: Double, cost: Double)] = [:] /// Begins the poll loop and the background ingest watch. Idempotent. func start() { @@ -64,24 +53,23 @@ final class LiveBurnViewModel: ObservableObject { } } - /// Stops the loop and the watch. Called when the live view disappears or the - /// app closes. + /// Stops the loop and the watch. func stop() { timer?.invalidate() timer = nil Task { await BurnLedger.shared.stopIngestWatch() } } - /// Switches the tracked provider, clearing the series so the new provider's - /// readings start fresh. - func select(_ provider: ProviderName) { - guard provider != self.provider else { return } - self.provider = provider - samples = [] - sessionTokens = 0 - sessionCost = 0 - unavailable = false - Task { await poll() } + func isEnabled(_ provider: ProviderName) -> Bool { enabled.contains(provider) } + + /// Show/hide a provider's line. Never lets the user hide the last one. + func toggle(_ provider: ProviderName) { + if enabled.contains(provider) { + guard enabled.count > 1 else { return } + enabled.remove(provider) + } else { + enabled.insert(provider) + } } private func poll() async { @@ -89,36 +77,41 @@ final class LiveBurnViewModel: ObservableObject { polling = true defer { polling = false } - let burnProvider = BurnLedger.burnProvider(for: provider) let now = Date() - guard let window = await BurnLedger.shared.summary( - provider: burnProvider, since: now.addingTimeInterval(-rateWindow) - ) else { - // Only flip to "unavailable" before we've ever shown data, so a - // transient query failure doesn't blank an established chart. - if samples.isEmpty { unavailable = true } - return - } - unavailable = false + var gotAny = false + for provider in providers { + let burnProvider = BurnLedger.burnProvider(for: provider) + guard let window = await BurnLedger.shared.summary( + provider: burnProvider, since: now.addingTimeInterval(-rateWindow) + ) else { continue } + gotAny = true - // Burn rate = tokens/cost over the trailing `rateWindow`, divided by it. - let tokensPerSecond = Double(window.tokens) / rateWindow - let dollarsPerMinute = window.cost / rateWindow * 60 + let tokensPerSecond = Double(window.tokens) / rateWindow + let dollarsPerMinute = window.cost / rateWindow * 60 + let dt = series[provider]?.last.map { now.timeIntervalSince($0.date) } ?? pollInterval + var totals = session[provider] ?? (tokens: 0, cost: 0) + totals.tokens += tokensPerSecond * dt + totals.cost += dollarsPerMinute / 60 * dt + session[provider] = totals - // Integrate the rate into a monotonic session total for the second line. - let dt = samples.last.map { now.timeIntervalSince($0.date) } ?? pollInterval - sessionTokens += tokensPerSecond * dt - sessionCost += dollarsPerMinute / 60 * dt + var samples = series[provider] ?? [] + samples.append(LiveBurnSample( + date: now, + cost: totals.cost, + tokens: Int(totals.tokens), + tokensPerSecond: tokensPerSecond, + dollarsPerMinute: dollarsPerMinute + )) + if samples.count > maxSamples { + samples.removeFirst(samples.count - maxSamples) + } + series[provider] = samples + } - samples.append(LiveBurnSample( - date: now, - cost: sessionCost, - tokens: Int(sessionTokens), - tokensPerSecond: tokensPerSecond, - dollarsPerMinute: dollarsPerMinute - )) - if samples.count > maxSamples { - samples.removeFirst(samples.count - maxSamples) + if gotAny { + unavailable = false + } else if series.values.allSatisfy({ $0.isEmpty }) { + unavailable = true // burn not installed / query failing } } } From d943f02ef65cc560de03ff62deed32ba21541c91 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 18 Jun 2026 10:12:23 -0400 Subject: [PATCH 3/6] Live tab: time-range switcher (5m/1h/12h/1d/7d) via summary --bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the real-time moving-average stream with a bucketed range chart. A segmented switch picks the window; each range maps to a burn --bucket size and refresh cadence (5m→30s/3s … 7d→12h/300s). Per provider, query `burn summary --since --bucket --json` and plot per-bucket rate (tokens/bucketSeconds) + a running cumulative line, keeping the overlaid per-provider lines (Claude coral, Codex blue/purple) and the show/hide toggles. Switching range clears + re-queries immediately and retimes the loop; stale in-flight results are dropped. Background `ingest --watch` still provides freshness. Depends on burn `--bucket` (PR #483): the release build.sh bundles a --bucket-capable burn once #483 is on main. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/macos/Sources/Burn/BurnLedger.swift | 34 ++++ apps/macos/Sources/Burn/LiveBurnView.swift | 28 ++- .../Sources/Burn/LiveBurnViewModel.swift | 180 ++++++++++++------ 3 files changed, 181 insertions(+), 61 deletions(-) diff --git a/apps/macos/Sources/Burn/BurnLedger.swift b/apps/macos/Sources/Burn/BurnLedger.swift index cc812c4b..edc68dcd 100644 --- a/apps/macos/Sources/Burn/BurnLedger.swift +++ b/apps/macos/Sources/Burn/BurnLedger.swift @@ -77,6 +77,40 @@ actor BurnLedger { return Summary(cost: cost, tokens: tokens) } + /// One bucket of a `burn summary --bucket` time-series. + struct TimeseriesPoint { + let date: Date + let tokens: Int + let cost: Double + } + + /// Per-bucket cost/token totals for `provider` over `[since, now]`, bucketed + /// by `bucket` (a burn duration like "30s"/"5m"/"1h"). Returns `nil` when + /// burn is unavailable or the query/parse fails. Runs `burn summary --bucket` + /// (read-only), so it's cheap to refresh. + func timeseries(provider: String, since: Date, bucket: String) async -> [TimeseriesPoint]? { + let iso = ISO8601DateFormatter().string(from: since) + let args = ["summary", "--provider", provider, "--since", iso, "--bucket", bucket, "--json"] + guard let output = runBurn(args), + let data = output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let buckets = json["buckets"] as? [[String: Any]] + else { return nil } + + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let plain = ISO8601DateFormatter() + + return buckets.compactMap { entry -> TimeseriesPoint? in + guard let start = entry["start"] as? String, + let date = parser.date(from: start) ?? plain.date(from: start) + else { return nil } + let tokens = (entry["totalTokens"] as? NSNumber)?.intValue ?? 0 + let cost = ((entry["totalCost"] as? [String: Any])?["total"] as? NSNumber)?.doubleValue ?? 0 + return TimeseriesPoint(date: date, tokens: tokens, cost: cost) + } + } + // MARK: - Long-lived ingest watch /// The running `burn ingest --watch` process, if any. `burn summary` is diff --git a/apps/macos/Sources/Burn/LiveBurnView.swift b/apps/macos/Sources/Burn/LiveBurnView.swift index ca3631ca..30136bd9 100644 --- a/apps/macos/Sources/Burn/LiveBurnView.swift +++ b/apps/macos/Sources/Burn/LiveBurnView.swift @@ -1,12 +1,13 @@ import SwiftUI import Charts -/// The "Burn rate" tab: a moving, streaming chart of token burn that updates in -/// real time, with one color-coded line per provider (Claude, Codex) overlaid -/// and per-provider show/hide toggles. The headline is the combined per-interval -/// burn rate (tokens/sec) across the shown providers, with a cumulative line. +/// The "Burn rate" tab: a bucketed chart of token burn over a selectable time +/// range (5m/1h/12h/1d/7d), with one color-coded line per provider (Claude, +/// Codex) overlaid and per-provider show/hide toggles. The headline is the +/// combined burn rate (tokens/sec) of the latest bucket across the shown +/// providers, with a cumulative line. /// -/// Owns a `LiveBurnViewModel` whose lifecycle (poll timer + background ingest +/// Owns a `LiveBurnViewModel` whose lifecycle (refresh timer + background ingest /// watch) is bound to this view's appearance. Falls back to a hint when burn /// can't be queried, mirroring how the rest of the app no-ops on a missing /// binary. @@ -18,6 +19,7 @@ struct LiveBurnView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { + rangePicker if viewModel.unavailable { hint } else if !hasData { @@ -34,6 +36,20 @@ struct LiveBurnView: View { .onDisappear { viewModel.stop() } } + /// Segmented switch for the chart's time range. + private var rangePicker: some View { + Picker("", selection: Binding( + get: { viewModel.range }, + set: { viewModel.setRange($0) } + )) { + ForEach(LiveRange.allCases) { range in + Text(range.label).tag(range) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + private var hasData: Bool { shown.contains { (viewModel.series[$0]?.count ?? 0) >= 2 } } @@ -45,7 +61,7 @@ struct LiveBurnView: View { VStack(alignment: .leading, spacing: 1) { Text("Burn rate") .font(.title3.weight(.bold)) - Text("live · combined") + Text("last \(viewModel.range.label) · combined") .font(.caption2) .foregroundStyle(.tertiary) } diff --git a/apps/macos/Sources/Burn/LiveBurnViewModel.swift b/apps/macos/Sources/Burn/LiveBurnViewModel.swift index c239cca5..954e1995 100644 --- a/apps/macos/Sources/Burn/LiveBurnViewModel.swift +++ b/apps/macos/Sources/Burn/LiveBurnViewModel.swift @@ -1,56 +1,109 @@ import SwiftUI -/// One polled reading of the live burn series for a provider: the running -/// session totals and the moving per-interval burn rate. +/// One bucket of the burn series for a provider: the per-bucket burn rate plus +/// the running cumulative totals across the selected range. struct LiveBurnSample: Identifiable { let id = UUID() let date: Date - /// Running session cost (USD) — integral of the rate. + /// Cumulative cost (USD) across the range up to and including this bucket. let cost: Double - /// Running session token count — integral of the rate. + /// Cumulative token count across the range up to and including this bucket. let tokens: Int - /// Moving-average tokens burned per second over the trailing rate window. + /// Tokens burned per second within this bucket. let tokensPerSecond: Double - /// Moving-average USD burned per minute over the trailing rate window. + /// USD burned per minute within this bucket. let dollarsPerMinute: Double } -/// Drives the live "Burn rate" tab. Polls `burn summary --json` for every -/// provider on a short timer and keeps a bounded per-provider ring buffer, so -/// the view can overlay one color-coded line per provider with show/hide -/// toggles. Freshness comes from a background `burn ingest --watch` (summary is -/// read-only); when burn is missing the series stay empty and the view hints. +/// The time window the live chart covers, with its burn-bucket size and refresh +/// cadence. `bucket` uses burn's `--bucket` grammar where `m` = minutes. +enum LiveRange: String, CaseIterable, Identifiable { + case m5, h1, h12, d1, d7 + + var id: String { rawValue } + + var label: String { + switch self { + case .m5: return "5m" + case .h1: return "1h" + case .h12: return "12h" + case .d1: return "1d" + case .d7: return "7d" + } + } + + /// How far back the window reaches. + var sinceSeconds: TimeInterval { + switch self { + case .m5: return 300 + case .h1: return 3_600 + case .h12: return 43_200 + case .d1: return 86_400 + case .d7: return 604_800 + } + } + + /// `--bucket` argument (burn grammar; `m` = minutes). + var bucketArg: String { + switch self { + case .m5: return "30s" + case .h1: return "5m" + case .h12: return "1h" + case .d1: return "2h" + case .d7: return "12h" + } + } + + /// Bucket width in seconds — the denominator for the per-bucket rate. + var bucketSeconds: Double { + switch self { + case .m5: return 30 + case .h1: return 300 + case .h12: return 3_600 + case .d1: return 7_200 + case .d7: return 43_200 + } + } + + /// How often to re-query (longer ranges change slowly). + var refreshInterval: TimeInterval { + switch self { + case .m5: return 3 + case .h1: return 15 + case .h12: return 60 + case .d1: return 120 + case .d7: return 300 + } + } +} + +/// Drives the live "Burn rate" tab. For the selected `range`, queries +/// `burn summary --bucket` per provider on a cadence and keeps a per-provider +/// series, so the view can overlay one color-coded line per provider with +/// show/hide toggles. Freshness comes from a background `burn ingest --watch` +/// (summary is read-only); when burn is missing the series stay empty and the +/// view hints. @MainActor final class LiveBurnViewModel: ObservableObject { - /// Per-provider rolling sample series (oldest first), capped at `maxSamples`. + /// Per-provider bucketed series (oldest first) for the current range. @Published private(set) var series: [ProviderName: [LiveBurnSample]] = [:] /// Providers whose line is currently shown (the toggles). @Published private(set) var enabled: Set = Set(ProviderName.allCases) + /// The selected time range. + @Published private(set) var range: LiveRange = .m5 /// True once we've confirmed burn can't be queried at all. @Published private(set) var unavailable = false - /// Poll `burn summary` (read-only, ~10ms). Freshness is handled by the watch. - private let pollInterval: TimeInterval = 1.5 - /// Trailing window the burn rate is averaged over — robust to window slide - /// and late ingests (unlike an inter-sample delta, which dips negative). - private let rateWindow: TimeInterval = 60 - /// Ring-buffer cap on samples kept on screen, per provider. - private let maxSamples = 150 - private let providers = ProviderName.allCases private var timer: Timer? - private var polling = false - /// Running session totals (integral of the rate) per provider. - private var session: [ProviderName: (tokens: Double, cost: Double)] = [:] + private var refreshing = false - /// Begins the poll loop and the background ingest watch. Idempotent. + /// Begins the refresh loop and the background ingest watch. Idempotent. func start() { guard timer == nil else { return } Task { await BurnLedger.shared.startIngestWatch() } - Task { await poll() } - timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in - Task { await self?.poll() } - } + Task { await refresh() } + scheduleTimer() } /// Stops the loop and the watch. @@ -72,45 +125,62 @@ final class LiveBurnViewModel: ObservableObject { } } - private func poll() async { - guard !polling else { return } - polling = true - defer { polling = false } + /// Switches the chart's time range, re-querying immediately and retiming the + /// refresh loop to the new range's cadence. + func setRange(_ newRange: LiveRange) { + guard newRange != range else { return } + range = newRange + series = [:] + if timer != nil { scheduleTimer() } // only retime when running + Task { await refresh() } + } + + private func scheduleTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: range.refreshInterval, repeats: true) { [weak self] _ in + Task { await self?.refresh() } + } + } + + private func refresh() async { + guard !refreshing else { return } + refreshing = true + defer { refreshing = false } - let now = Date() + let range = self.range + let since = Date().addingTimeInterval(-range.sinceSeconds) + var next: [ProviderName: [LiveBurnSample]] = [:] var gotAny = false + for provider in providers { let burnProvider = BurnLedger.burnProvider(for: provider) - guard let window = await BurnLedger.shared.summary( - provider: burnProvider, since: now.addingTimeInterval(-rateWindow) + guard let points = await BurnLedger.shared.timeseries( + provider: burnProvider, since: since, bucket: range.bucketArg ) else { continue } gotAny = true - let tokensPerSecond = Double(window.tokens) / rateWindow - let dollarsPerMinute = window.cost / rateWindow * 60 - let dt = series[provider]?.last.map { now.timeIntervalSince($0.date) } ?? pollInterval - var totals = session[provider] ?? (tokens: 0, cost: 0) - totals.tokens += tokensPerSecond * dt - totals.cost += dollarsPerMinute / 60 * dt - session[provider] = totals - - var samples = series[provider] ?? [] - samples.append(LiveBurnSample( - date: now, - cost: totals.cost, - tokens: Int(totals.tokens), - tokensPerSecond: tokensPerSecond, - dollarsPerMinute: dollarsPerMinute - )) - if samples.count > maxSamples { - samples.removeFirst(samples.count - maxSamples) + var cumulativeTokens = 0 + var cumulativeCost = 0.0 + next[provider] = points.map { point in + cumulativeTokens += point.tokens + cumulativeCost += point.cost + return LiveBurnSample( + date: point.date, + cost: cumulativeCost, + tokens: cumulativeTokens, + tokensPerSecond: Double(point.tokens) / range.bucketSeconds, + dollarsPerMinute: point.cost / range.bucketSeconds * 60 + ) } - series[provider] = samples } + // Ignore a result for a range the user switched away from mid-flight. + guard range == self.range else { return } + if gotAny { + series = next unavailable = false - } else if series.values.allSatisfy({ $0.isEmpty }) { + } else { unavailable = true // burn not installed / query failing } } From 80c42ae41b997b2226945464a55cab2e9076fb30 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 18 Jun 2026 11:41:37 -0400 Subject: [PATCH 4/6] Live charts: adaptive time (x) axis + softer gridlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "legend for time" — an x-axis whose labels adapt to the selected range (clock time for 5m–1d, calendar date for 7d) on both the rate and cumulative charts. Soften gridlines (0.08→0.06) for a calmer, more readable chart. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/macos/Sources/Burn/LiveBurnView.swift | 30 +++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/macos/Sources/Burn/LiveBurnView.swift b/apps/macos/Sources/Burn/LiveBurnView.swift index 30136bd9..78050c07 100644 --- a/apps/macos/Sources/Burn/LiveBurnView.swift +++ b/apps/macos/Sources/Burn/LiveBurnView.swift @@ -110,7 +110,7 @@ struct LiveBurnView: View { .chartForegroundStyleScale(domain: shown.map(\.displayName), range: shown.map(\.brandColor)) .chartLegend(.hidden) // the toggles serve as the legend .chartXScale(domain: xDomain) - .chartXAxis(.hidden) + .chartXAxis { timeAxis(desiredCount: 4) } .chartYAxis { tokenAxis(desiredCount: nil) } .chartCard() } @@ -133,14 +133,14 @@ struct LiveBurnView: View { .chartForegroundStyleScale(domain: shown.map(\.displayName), range: shown.map(\.brandColor)) .chartLegend(.hidden) .chartXScale(domain: xDomain) - .chartXAxis(.hidden) + .chartXAxis { timeAxis(desiredCount: 4) } .chartYAxis { tokenAxis(desiredCount: 3) } .chartCard() } private func tokenAxis(desiredCount: Int?) -> some AxisContent { AxisMarks(position: .leading, values: desiredCount.map { .automatic(desiredCount: $0) } ?? .automatic) { value in - AxisGridLine().foregroundStyle(Color.primary.opacity(0.08)) + AxisGridLine().foregroundStyle(Color.primary.opacity(0.06)) AxisValueLabel { if let v = value.as(Double.self) { Text(tokenAxisLabel(v)).font(.caption2).foregroundStyle(.tertiary) @@ -149,6 +149,30 @@ struct LiveBurnView: View { } } + /// Time (x) axis with labels that adapt to the selected range — the "legend + /// for time" across the bottom of each chart. + private func timeAxis(desiredCount: Int) -> some AxisContent { + AxisMarks(values: .automatic(desiredCount: desiredCount)) { value in + AxisGridLine().foregroundStyle(Color.primary.opacity(0.06)) + AxisValueLabel { + if let date = value.as(Date.self) { + Text(date, format: xAxisFormat) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + + /// Label granularity per range: clock time for intraday windows, calendar + /// date for the multi-day window. + private var xAxisFormat: Date.FormatStyle { + switch viewModel.range { + case .m5, .h1, .h12, .d1: return .dateTime.hour().minute() + case .d7: return .dateTime.month(.abbreviated).day() + } + } + /// Span the union of all shown series so lines slide left together. private var xDomain: ClosedRange { let dates = shown.flatMap { viewModel.series[$0] ?? [] }.map(\.date) From d8a2a77b69be48ef42277bd3adc431b7b10699af Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 18 Jun 2026 19:44:19 -0400 Subject: [PATCH 5/6] Live tab: show range totals (tokens + cost) in the headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the selected timeframe, the headline now leads with the combined total tokens and total cost across shown providers (e.g. "Last 7d · 2.1B tokens · $1,444"), with the current burn rate kept as secondary context. Totals come from the cumulative line's last point per provider. Add a billions (B) tier to the token formatter for the large multi-day sums. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/macos/Sources/Burn/LiveBurnView.swift | 34 +++++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/macos/Sources/Burn/LiveBurnView.swift b/apps/macos/Sources/Burn/LiveBurnView.swift index 78050c07..cec99236 100644 --- a/apps/macos/Sources/Burn/LiveBurnView.swift +++ b/apps/macos/Sources/Burn/LiveBurnView.swift @@ -59,36 +59,47 @@ struct LiveBurnView: View { private var headline: some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 1) { - Text("Burn rate") + Text("Last \(viewModel.range.label)") .font(.title3.weight(.bold)) - Text("last \(viewModel.range.label) · combined") + Text("total burn") .font(.caption2) .foregroundStyle(.tertiary) } Spacer() VStack(alignment: .trailing, spacing: 1) { - Text(rateLabel) + Text(totalTokensLabel) .font(.headline.weight(.bold)) .monospacedDigit() - Text(spendLabel) - .font(.caption2) - .foregroundStyle(.secondary) - .monospacedDigit() + HStack(spacing: 6) { + Text(totalCostLabel) + Text("·") + Text(rateLabel) + } + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() } } } + /// Range totals — the cumulative line's last point per shown provider, summed. + private var rangeTotalTokens: Int { + shown.reduce(0) { $0 + (viewModel.series[$1]?.last?.tokens ?? 0) } + } + private var rangeTotalCost: Double { + shown.reduce(0) { $0 + (viewModel.series[$1]?.last?.cost ?? 0) } + } + private var totalTokensLabel: String { "\(tokenAxisLabel(Double(rangeTotalTokens))) tokens" } + private var totalCostLabel: String { String(format: "$%.2f", rangeTotalCost) } + + /// Current burn rate (latest bucket) across shown providers — secondary context. private var totalRate: Double { shown.reduce(0) { $0 + (viewModel.series[$1]?.last?.tokensPerSecond ?? 0) } } - private var totalSpend: Double { - shown.reduce(0) { $0 + (viewModel.series[$1]?.last?.dollarsPerMinute ?? 0) } - } private var rateLabel: String { totalRate >= 1000 ? String(format: "%.1fk tok/s", totalRate / 1000) : "\(Int(totalRate.rounded())) tok/s" } - private var spendLabel: String { String(format: "$%.2f/min", totalSpend) } // MARK: Charts @@ -184,6 +195,7 @@ struct LiveBurnView: View { } private func tokenAxisLabel(_ value: Double) -> String { + if value >= 1_000_000_000 { return String(format: "%.2fB", value / 1_000_000_000) } if value >= 1_000_000 { return String(format: "%.1fM", value / 1_000_000) } if value >= 1_000 { return String(format: "%.0fk", value / 1_000) } return "\(Int(value))" From b94c7a0271a63d3dffb508b4f1e3aa3fcc3d9227 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 18 Jun 2026 21:34:45 -0400 Subject: [PATCH 6/6] Live tab: fix stuck spinner on fast range switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setRange cleared the series and kicked a refresh, but refresh's hard `guard !refreshing` dropped the new request while one was in flight, and the in-flight one discarded its result as stale — so nothing repopulated until the next timer tick (up to 5 min on 7d), leaving the warming spinner stuck. Coalesce instead: a refresh requested while one runs sets `refreshAgain`, and the running refresh loops one more pass for the latest range. Publish only when the queried range still matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/Burn/LiveBurnViewModel.swift | 86 +++++++++++-------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/apps/macos/Sources/Burn/LiveBurnViewModel.swift b/apps/macos/Sources/Burn/LiveBurnViewModel.swift index 954e1995..d08ea1ee 100644 --- a/apps/macos/Sources/Burn/LiveBurnViewModel.swift +++ b/apps/macos/Sources/Burn/LiveBurnViewModel.swift @@ -97,6 +97,9 @@ final class LiveBurnViewModel: ObservableObject { private let providers = ProviderName.allCases private var timer: Timer? private var refreshing = false + /// Set when a refresh is requested while one is in flight, so the running + /// one does another pass (for the latest range) instead of being dropped. + private var refreshAgain = false /// Begins the refresh loop and the background ingest watch. Idempotent. func start() { @@ -143,45 +146,58 @@ final class LiveBurnViewModel: ObservableObject { } private func refresh() async { - guard !refreshing else { return } + // Coalesce: if a refresh is already running, ask it to do one more pass + // (for whatever the latest range is) instead of dropping this request. + // Otherwise a fast range switch clears the series and then has its + // refresh skipped, leaving the warming spinner until the next timer tick + // (up to 5 min on the 7d range). + if refreshing { + refreshAgain = true + return + } refreshing = true defer { refreshing = false } - let range = self.range - let since = Date().addingTimeInterval(-range.sinceSeconds) - var next: [ProviderName: [LiveBurnSample]] = [:] - var gotAny = false - - for provider in providers { - let burnProvider = BurnLedger.burnProvider(for: provider) - guard let points = await BurnLedger.shared.timeseries( - provider: burnProvider, since: since, bucket: range.bucketArg - ) else { continue } - gotAny = true - - var cumulativeTokens = 0 - var cumulativeCost = 0.0 - next[provider] = points.map { point in - cumulativeTokens += point.tokens - cumulativeCost += point.cost - return LiveBurnSample( - date: point.date, - cost: cumulativeCost, - tokens: cumulativeTokens, - tokensPerSecond: Double(point.tokens) / range.bucketSeconds, - dollarsPerMinute: point.cost / range.bucketSeconds * 60 - ) + repeat { + refreshAgain = false + let range = self.range + let since = Date().addingTimeInterval(-range.sinceSeconds) + var next: [ProviderName: [LiveBurnSample]] = [:] + var gotAny = false + + for provider in providers { + let burnProvider = BurnLedger.burnProvider(for: provider) + guard let points = await BurnLedger.shared.timeseries( + provider: burnProvider, since: since, bucket: range.bucketArg + ) else { continue } + gotAny = true + + var cumulativeTokens = 0 + var cumulativeCost = 0.0 + next[provider] = points.map { point in + cumulativeTokens += point.tokens + cumulativeCost += point.cost + return LiveBurnSample( + date: point.date, + cost: cumulativeCost, + tokens: cumulativeTokens, + tokensPerSecond: Double(point.tokens) / range.bucketSeconds, + dollarsPerMinute: point.cost / range.bucketSeconds * 60 + ) + } } - } - - // Ignore a result for a range the user switched away from mid-flight. - guard range == self.range else { return } - if gotAny { - series = next - unavailable = false - } else { - unavailable = true // burn not installed / query failing - } + // Only publish if the range still matches what we queried. A switch + // during the query sets refreshAgain, so the loop reruns for the new + // range rather than showing stale data or getting stuck empty. + if range == self.range { + if gotAny { + series = next + unavailable = false + } else { + unavailable = true // burn not installed / query failing + } + } + } while refreshAgain } }