diff --git a/apps/macos/Sources/Burn/BrandIcon.swift b/apps/macos/Sources/Burn/BrandIcon.swift index 2639b91..b3a0618 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/BurnLedger.swift b/apps/macos/Sources/Burn/BurnLedger.swift index f849d12..edc68dc 100644 --- a/apps/macos/Sources/Burn/BurnLedger.swift +++ b/apps/macos/Sources/Burn/BurnLedger.swift @@ -77,15 +77,72 @@ 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"]) + /// 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 + /// 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/ContentView.swift b/apps/macos/Sources/Burn/ContentView.swift index 18d6639..6dbf6d7 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 22b352b..cec9923 100644 --- a/apps/macos/Sources/Burn/LiveBurnView.swift +++ b/apps/macos/Sources/Burn/LiveBurnView.swift @@ -1,153 +1,193 @@ 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. +/// 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 -/// 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. +/// 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. 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) { + rangePicker 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 + /// 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 } + } + + // MARK: Headline (combined across shown providers) 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("live · trailing 5 min") + Text("total burn") .font(.caption2) .foregroundStyle(.tertiary) } Spacer() VStack(alignment: .trailing, spacing: 1) { - Text(rateLabel) + Text(totalTokensLabel) .font(.headline.weight(.bold)) - .foregroundStyle(rateColor) - .monospacedDigit() - Text(spendLabel) - .font(.caption2) - .foregroundStyle(.secondary) .monospacedDigit() + HStack(spacing: 6) { + Text(totalCostLabel) + Text("·") + Text(rateLabel) + } + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() } } } - 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" + /// 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) } - private var spendLabel: String { - let perMin = latest?.dollarsPerMinute ?? 0 - return String(format: "$%.2f/min", perMin) + /// 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 rateLabel: String { + totalRate >= 1000 ? String(format: "%.1fk tok/s", totalRate / 1000) + : "\(Int(totalRate.rounded())) tok/s" } // 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 { timeAxis(desiredCount: 4) } + .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) - } + .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.06)) + AxisValueLabel { + if let v = value.as(Double.self) { + Text(tokenAxisLabel(v)).font(.caption2).foregroundStyle(.tertiary) + } + } + } + } + + /// 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) } } } - .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. + /// 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 = 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 } @@ -155,11 +195,38 @@ 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))" } + // 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 ae2c8b2..d08ea1e 100644 --- a/apps/macos/Sources/Burn/LiveBurnViewModel.swift +++ b/apps/macos/Sources/Burn/LiveBurnViewModel.swift @@ -1,128 +1,203 @@ 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 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 - /// Cumulative USD cost over the rolling window at this instant. + /// Cumulative cost (USD) across the range up to and including this bucket. let cost: Double - /// Cumulative token count over the rolling window at this instant. + /// Cumulative token count across the range up to and including this bucket. let tokens: Int - /// Tokens burned per second since the previous sample (0 for the first). + /// Tokens burned per second within this bucket. let tokensPerSecond: Double - /// USD burned per minute since the previous sample (0 for the first). + /// USD burned per minute within this bucket. 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. +/// 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 { - /// 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 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 - /// 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 - /// 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"). - private let rateWindow: TimeInterval = 60 - /// Ring-buffer cap on samples kept on screen. - 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 - } + 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 ingest+poll loop. Idempotent. + /// Begins the refresh loop and the background ingest watch. Idempotent. func start() { guard timer == nil else { return } - Task { await poll() } - timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in - Task { await self?.poll() } - } + Task { await BurnLedger.shared.startIngestWatch() } + Task { await refresh() } + scheduleTimer() } - /// Stops the loop. 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() } + } + + 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) + } } - /// 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() } + /// 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 poll() async { - guard !polling else { return } - 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( - 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 + private func scheduleTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: range.refreshInterval, repeats: true) { [weak self] _ in + Task { await self?.refresh() } } - unavailable = false - - // Burn rate = tokens/cost over the trailing `rateWindow`, divided by it. - let tokensPerSecond = Double(window.tokens) / rateWindow - let dollarsPerMinute = window.cost / rateWindow * 60 - - // 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 - - samples.append(LiveBurnSample( - date: now, - cost: sessionCost, - tokens: Int(sessionTokens), - tokensPerSecond: tokensPerSecond, - dollarsPerMinute: dollarsPerMinute - )) - if samples.count > maxSamples { - samples.removeFirst(samples.count - maxSamples) + } + + private func refresh() async { + // 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 } + + 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 + ) + } + } + + // 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 } }