From 4ee52f38dc9ad8c52c020fd1dcaba68eeab7e5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BD=98=E6=9D=B0?= <1351982581@qq.com> Date: Wed, 12 Aug 2026 14:07:44 +0800 Subject: [PATCH] fix: harden live usage sync realtime and spend accuracy Poll events head while the panel is open, burst within each sync wave, derive today/Auto spend from live merged events and first-party aggregates, and stop accumulating in-progress billing snapshots during live merge. --- .../Localization/L10n.swift | 8 +- .../Models/UsageModels.swift | 6 +- .../Services/RequestPacer.swift | 46 +++-- .../Services/UsageAnalytics.swift | 113 ++++++++++-- .../Services/UsageEventsMemoryCache.swift | 97 +++++++++++ .../Services/UsageLiveHeadSync.swift | 55 ++++++ .../ViewModels/UsageViewModel.swift | 163 +++++++----------- .../Views/MenuContentView.swift | 2 + 8 files changed, 360 insertions(+), 130 deletions(-) create mode 100644 Sources/CursorUsageMenuBar/Services/UsageEventsMemoryCache.swift create mode 100644 Sources/CursorUsageMenuBar/Services/UsageLiveHeadSync.swift diff --git a/Sources/CursorUsageMenuBar/Localization/L10n.swift b/Sources/CursorUsageMenuBar/Localization/L10n.swift index 4742086..f5cc141 100644 --- a/Sources/CursorUsageMenuBar/Localization/L10n.swift +++ b/Sources/CursorUsageMenuBar/Localization/L10n.swift @@ -392,8 +392,8 @@ enum L10n { .clear: "Clear", .autoRefresh: "Auto refresh", .requestInterval: "Request interval", - .requestIntervalValue: "10–20s random", - .pacerHint: "Each API call and refresh cycle waits a random interval to avoid fixed traffic patterns.", + .requestIntervalValue: "panel 3s / cycle 5–8s", + .pacerHint: "While the panel is open, event details refresh every ~3s and metrics every ~8s. Background sync waits 5–8s between cycles; requests inside a cycle are not delayed.", .status: "Status", .configured: "Configured", .notConfigured: "Not configured", @@ -637,8 +637,8 @@ enum L10n { .clear: "清除", .autoRefresh: "自动刷新", .requestInterval: "请求间隔", - .requestIntervalValue: "10–20 秒随机", - .pacerHint: "每次 API 请求与自动刷新轮次之间都会随机等待,降低固定访问节奏。", + .requestIntervalValue: "面板 3 秒 / 后台 5–8 秒", + .pacerHint: "面板打开时:明细约每 3 秒刷新,指标约每 8 秒。后台轮次间隔 5–8 秒;同一轮内请求不再逐个等待。", .status: "状态", .configured: "已配置", .notConfigured: "未配置", diff --git a/Sources/CursorUsageMenuBar/Models/UsageModels.swift b/Sources/CursorUsageMenuBar/Models/UsageModels.swift index 6f8446f..9a8b7ce 100644 --- a/Sources/CursorUsageMenuBar/Models/UsageModels.swift +++ b/Sources/CursorUsageMenuBar/Models/UsageModels.swift @@ -241,19 +241,21 @@ struct UsageEvent: Codable, Sendable, Identifiable { let totalCents: Double? } - var id: String { + /// 稳定身份:不含金额/Token。进行中请求的 chargedCents 会变,若写入 id 会导致每次轮询都当成新事件。 + var identityKey: String { [ timestamp, model, kind, cloudAgentId, conversationId, - chargedCents.map { String($0) }, ] .compactMap { $0 } .joined(separator: "|") } + var id: String { identityKey } + var eventDate: Date? { guard let timestamp, let milliseconds = Double(timestamp) else { return nil } return Date(timeIntervalSince1970: milliseconds / 1000) diff --git a/Sources/CursorUsageMenuBar/Services/RequestPacer.swift b/Sources/CursorUsageMenuBar/Services/RequestPacer.swift index f201cbb..71f9104 100644 --- a/Sources/CursorUsageMenuBar/Services/RequestPacer.swift +++ b/Sources/CursorUsageMenuBar/Services/RequestPacer.swift @@ -1,14 +1,19 @@ import Foundation -/// 在每次 API 请求前随机等待 10–20 秒,降低固定节奏触发风控的概率。 -/// 同一操作内的连续分页(如图表全量拉取)可进入 burst 模式,页与页之间不再等待。 +/// API 节奏控制:轮次之间拉开间隔;**同一次同步波次内**不再逐请求等待。 +/// +/// 历史问题:并发 `async let` 会串行排队在 actor 上,每个请求各睡 10–20s, +/// 导致一轮同步拖到一分钟以上。现改为「波次前等一次,波次内 burst」。 actor RequestPacer { static let shared = RequestPacer() - private let minSeconds = 10.0 - private let maxSeconds = 20.0 + /// 后台整轮指标同步的间隔(秒) + private let cycleMinSeconds = 5.0 + private let cycleMaxSeconds = 8.0 + private var skipNextWait = true private var burstCount = 0 + private var waveActive = false func beginBurst() { burstCount += 1 @@ -24,23 +29,44 @@ actor RequestPacer { return try await operation() } + /// 一整轮 dashboard 同步:先按轮次间隔等待(或跳过首次),波次内所有请求免等待。 + func withSyncWave(_ operation: () async throws -> T) async rethrows -> T { + await waitForNextWaveGate() + waveActive = true + defer { waveActive = false } + return try await operation() + } + func waitBeforeRequest() async { - if burstCount > 0 { return } + if burstCount > 0 || waveActive { return } if skipNextWait { skipNextWait = false return } - let seconds = nextDelaySeconds() - let nanoseconds = UInt64(seconds * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) + await sleep(seconds: nextCycleDelaySeconds()) } func nextDelaySeconds() -> Double { - Double.random(in: minSeconds...maxSeconds) + nextCycleDelaySeconds() } func sleepUntilNextCycle() async { - let seconds = nextDelaySeconds() + await sleep(seconds: nextCycleDelaySeconds()) + } + + private func waitForNextWaveGate() async { + if skipNextWait { + skipNextWait = false + return + } + await sleep(seconds: nextCycleDelaySeconds()) + } + + private func nextCycleDelaySeconds() -> Double { + Double.random(in: cycleMinSeconds...cycleMaxSeconds) + } + + private func sleep(seconds: Double) async { let nanoseconds = UInt64(seconds * 1_000_000_000) try? await Task.sleep(nanoseconds: nanoseconds) } diff --git a/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift b/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift index 8b0e550..162eb77 100644 --- a/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift +++ b/Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift @@ -77,7 +77,9 @@ enum UsageAnalytics { return explicitSpend } - /// Auto 池花费:接口常不再返回 bonusSpend;优先 aggregated 第一方汇总,其次 total − API 已用 + /// Auto 池花费:优先 aggregated 第一方模型汇总。 + /// 注意:`bonusSpend` 常是 period.totalSpend − includedSpend(超额/bonus 口径), + /// **不是** Auto+Composer 池花费,不能优先采用(否则会显示成 ~$253 这种偏小值)。 static func resolveAutoSpendCents( summary: UsageSummary?, period: PeriodUsageResponse?, @@ -87,13 +89,6 @@ enum UsageAnalytics { ) -> Double? { let periodPlan = period?.planUsage - if let bonusSpend = periodPlan?.bonusSpend, bonusSpend > 0 { - return bonusSpend - } - if let autoSpend = periodPlan?.autoSpend, autoSpend > 0 { - return autoSpend - } - if let fromAggregated = poolSpendCents( from: aggregated, autoBucketModels: autoBucketModels, @@ -102,14 +97,23 @@ enum UsageAnalytics { return fromAggregated } - let totalSpend = periodPlan?.totalSpend - ?? aggregated?.totalCostCents + if let autoSpend = periodPlan?.autoSpend, autoSpend > 0 { + return autoSpend + } + + let totalSpend = aggregated?.totalCostCents + ?? periodPlan?.totalSpend ?? summary?.billingPlan?.used if let totalSpend, let apiUsedCents, totalSpend >= apiUsedCents { let derived = totalSpend - apiUsedCents if derived > 0 { return derived } } + // 仅作最后兜底;多数账号此字段含义已不是 Auto 池 + if let bonusSpend = periodPlan?.bonusSpend, bonusSpend > 0 { + return bonusSpend + } + return nil } @@ -844,16 +848,89 @@ enum UsageAnalytics { ) } - /// 将最新一页事件合并进缓存:同 id 以 fresh 为准,按时间新→旧排序。 + /// 将最新一页合并进缓存。 + /// - 新鲜第 1 页覆盖其时间窗(避免轮询把已从服务端消失的中间快照堆成「飞涨」) + /// - 同 identity 保留 Token/金额更大的版本 + /// - 折叠同对话+模型下被更大快照取代的进行中中间态 static func mergeEvents(fresh: [UsageEvent], into cached: [UsageEvent]) -> [UsageEvent] { - guard !fresh.isEmpty else { return cached } - guard !cached.isEmpty else { return sortEventsByNewest(fresh) } + guard !fresh.isEmpty else { + return sortEventsByNewest(collapseSupersededSnapshots(cached)) + } - var byID: [String: UsageEvent] = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) }) - for event in fresh { - byID[event.id] = event + let freshDeduped = dedupeByIdentity(fresh) + guard !cached.isEmpty else { + return sortEventsByNewest(collapseSupersededSnapshots(freshDeduped)) } - return sortEventsByNewest(Array(byID.values)) + + let freshOldest = freshDeduped.compactMap(\.eventDate).min() ?? .distantPast + // 第 1 页是该时间窗内的权威数据:丢掉窗内旧缓存,再写入 fresh + let retainedOlder = cached.filter { ($0.eventDate ?? .distantPast) < freshOldest } + let merged = dedupeByIdentity(retainedOlder + freshDeduped) + return sortEventsByNewest(collapseSupersededSnapshots(merged)) + } + + private static func dedupeByIdentity(_ events: [UsageEvent]) -> [UsageEvent] { + var byKey: [String: UsageEvent] = [:] + for event in events { + let key = event.identityKey + if let existing = byKey[key] { + byKey[key] = preferredEvent(event, existing) + } else { + byKey[key] = event + } + } + return Array(byKey.values) + } + + private static func preferredEvent(_ a: UsageEvent, _ b: UsageEvent) -> UsageEvent { + if a.totalTokens != b.totalTokens { + return a.totalTokens > b.totalTokens ? a : b + } + let aCents = a.chargedCents ?? -1 + let bCents = b.chargedCents ?? -1 + if aCents != bCents { + return aCents > bCents ? a : b + } + return a + } + + /// 同一 conversation + model 下,短时间内 Token 明显增长的前序事件视为进行中快照并丢弃。 + /// 正常连续 tool 调用(Token 接近)不会被折叠。 + static func collapseSupersededSnapshots(_ events: [UsageEvent]) -> [UsageEvent] { + guard events.count > 1 else { return events } + + let grouped = Dictionary(grouping: events) { event in + [ + event.conversationId ?? "", + event.model ?? "", + event.cloudAgentId ?? "", + ].joined(separator: "|") + } + + var survivors: [UsageEvent] = [] + survivors.reserveCapacity(events.count) + + for (_, group) in grouped { + let ordered = group.sorted { + ($0.eventDate ?? .distantPast) < ($1.eventDate ?? .distantPast) + } + var kept: [UsageEvent] = [] + for event in ordered { + kept.removeAll { isSupersededSnapshot($0, by: event) } + kept.append(event) + } + survivors.append(contentsOf: kept) + } + return survivors + } + + private static func isSupersededSnapshot(_ previous: UsageEvent, by next: UsageEvent) -> Bool { + guard let previousDate = previous.eventDate, let nextDate = next.eventDate else { return false } + let delta = nextDate.timeIntervalSince(previousDate) + guard delta > 0, delta <= 120 else { return false } + guard previous.totalTokens > 0, next.totalTokens > previous.totalTokens else { return false } + let growth = Double(next.totalTokens) / Double(previous.totalTokens) + return growth >= 1.15 } private static func sortEventsByNewest(_ events: [UsageEvent]) -> [UsageEvent] { @@ -861,7 +938,7 @@ enum UsageAnalytics { let left = lhs.eventDate ?? .distantPast let right = rhs.eventDate ?? .distantPast if left != right { return left > right } - return lhs.id > rhs.id + return lhs.identityKey > rhs.identityKey } } diff --git a/Sources/CursorUsageMenuBar/Services/UsageEventsMemoryCache.swift b/Sources/CursorUsageMenuBar/Services/UsageEventsMemoryCache.swift new file mode 100644 index 0000000..cb9caec --- /dev/null +++ b/Sources/CursorUsageMenuBar/Services/UsageEventsMemoryCache.swift @@ -0,0 +1,97 @@ +import Foundation + +/// 用量明细的内存 + 磁盘缓存读写,供 ViewModel 调用。 +@MainActor +final class UsageEventsMemoryCache { + private var memory: (key: String, store: UsageEventsCache.Store)? + + func clearAll() { + memory = nil + UsageEventsCache.clearAll() + } + + func clear(startMs: String, endMs: String) { + UsageEventsCache.clear(startMs: startMs, endMs: endMs) + if memory?.key == key(startMs: startMs, endMs: endMs) { + memory = nil + } + } + + func load(startMs: String, endMs: String) -> UsageEventsCache.Store? { + let cacheKey = key(startMs: startMs, endMs: endMs) + if let memory, memory.key == cacheKey { + return memory.store + } + guard let store = UsageEventsCache.load(startMs: startMs, endMs: endMs) else { return nil } + + // 清理历史 live 轮询堆出来的进行中中间快照 + let normalizedEvents = UsageAnalytics.mergeEvents(fresh: store.events, into: []) + let removed = store.events.count - normalizedEvents.count + let normalized = UsageEventsCache.Store( + billingCycleStartMs: store.billingCycleStartMs, + billingCycleEndMs: store.billingCycleEndMs, + totalCount: max(store.totalCount - max(0, removed), normalizedEvents.count), + fetchedAt: store.fetchedAt, + events: normalizedEvents + ) + if removed > 0 { + UsageEventsCache.save( + events: normalized.events, + totalCount: normalized.totalCount, + startMs: startMs, + endMs: endMs + ) + } + memory = (cacheKey, normalized) + return normalized + } + + func save(events: [UsageEvent], totalCount: Int, startMs: String, endMs: String) { + UsageEventsCache.save(events: events, totalCount: totalCount, startMs: startMs, endMs: endMs) + memory = ( + key(startMs: startMs, endMs: endMs), + UsageEventsCache.Store( + billingCycleStartMs: startMs, + billingCycleEndMs: endMs, + totalCount: totalCount, + fetchedAt: Date(), + events: events + ) + ) + } + + func page(startMs: String, endMs: String, page: Int, pageSize: Int) -> UsageEventsPage? { + guard UsageEventsCache.canServePage( + startMs: startMs, + endMs: endMs, + page: page, + pageSize: pageSize + ) else { return nil } + + if let store = load(startMs: startMs, endMs: endMs) { + return UsageEventsPage.slice( + from: store.events, + totalCount: store.totalCount, + page: page, + pageSize: pageSize + ) + } + return UsageEventsCache.page( + startMs: startMs, + endMs: endMs, + page: page, + pageSize: pageSize + ) + } + + func isStale(startMs: String?, endMs: String?, expectedTotal: Int) -> Bool { + guard let startMs, let endMs, let store = load(startMs: startMs, endMs: endMs) else { + return true + } + return store.events.count < expectedTotal + } + + private func key(startMs: String, endMs: String) -> String { + "\(startMs)|\(endMs)" + } +} diff --git a/Sources/CursorUsageMenuBar/Services/UsageLiveHeadSync.swift b/Sources/CursorUsageMenuBar/Services/UsageLiveHeadSync.swift new file mode 100644 index 0000000..baff682 --- /dev/null +++ b/Sources/CursorUsageMenuBar/Services/UsageLiveHeadSync.swift @@ -0,0 +1,55 @@ +import Foundation + +/// 面板打开时高频拉取用量明细第 1 页,尽量贴近官网实时性。 +@MainActor +final class UsageLiveHeadSync { + /// 面板可见时明细头刷新间隔 + static let headPollIntervalNanoseconds: UInt64 = 3_000_000_000 + /// 面板可见时轻量指标刷新间隔(summary / 第 1 页等) + static let metricsPollIntervalNanoseconds: UInt64 = 8_000_000_000 + + private var headTask: Task? + private var metricsTask: Task? + private var isPanelVisible = false + + var panelVisible: Bool { isPanelVisible } + + func setPanelVisible( + _ visible: Bool, + onHeadTick: @escaping @MainActor () async -> Void, + onMetricsTick: @escaping @MainActor () async -> Void + ) { + isPanelVisible = visible + headTask?.cancel() + metricsTask?.cancel() + headTask = nil + metricsTask = nil + + guard visible else { return } + + headTask = Task { @MainActor in + await onHeadTick() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.headPollIntervalNanoseconds) + guard !Task.isCancelled else { break } + await onHeadTick() + } + } + + metricsTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.metricsPollIntervalNanoseconds) + guard !Task.isCancelled else { break } + await onMetricsTick() + } + } + } + + func cancel() { + isPanelVisible = false + headTask?.cancel() + metricsTask?.cancel() + headTask = nil + metricsTask = nil + } +} diff --git a/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift b/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift index 9416c6c..353a261 100644 --- a/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift +++ b/Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift @@ -22,12 +22,13 @@ final class UsageViewModel: ObservableObject { @Published var changeBanner: UsageChangeAlert? private let apiClient = CursorAPIClient() + private let liveHeadSync = UsageLiveHeadSync() + private let eventsCache = UsageEventsMemoryCache() private var backgroundSyncTask: Task? private var chartSyncTask: Task? private var metricsFingerprint: Int? private var chartFingerprint: Int? private var isReadyForChangeNotifications = false - private var memoryUsageEventsCache: (key: String, store: UsageEventsCache.Store)? private var languageObserver: AnyCancellable? init() { @@ -44,6 +45,7 @@ final class UsageViewModel: ObservableObject { deinit { backgroundSyncTask?.cancel() chartSyncTask?.cancel() + // liveHeadSync.cancel() is MainActor; deinit cannot hop — tasks cancel via weak self loops. } var summary: UsageSummary? { dashboard.summary } @@ -74,7 +76,7 @@ final class UsageViewModel: ObservableObject { let cycle = dashboard.usageLimit?.cyclePercentUsed ?? summary?.billingPlan?.totalPercentUsed ?? dashboard.periodUsage?.planUsage?.totalPercentUsed - let today = dashboard.todayStats.dailyPercent + let today = displayedDayStats.dailyPercent if let cycle, let today, today > 0 { return "\(formatMenuPercent(cycle))% · \(formatMenuPercent(today))%" @@ -122,8 +124,7 @@ final class UsageViewModel: ObservableObject { chartFingerprint = nil isReadyForChangeNotifications = false changeBanner = nil - memoryUsageEventsCache = nil - UsageEventsCache.clearAll() + eventsCache.clearAll() startBackgroundSync() } catch { errorMessage = error.localizedDescription @@ -142,8 +143,8 @@ final class UsageViewModel: ObservableObject { chartFingerprint = nil isReadyForChangeNotifications = false changeBanner = nil - memoryUsageEventsCache = nil - UsageEventsCache.clearAll() + eventsCache.clearAll() + liveHeadSync.cancel() backgroundSyncTask?.cancel() chartSyncTask?.cancel() } catch { @@ -151,10 +152,26 @@ final class UsageViewModel: ObservableObject { } } + /// 菜单栏面板打开/关闭:打开时每 3s 拉明细头、每 8s 拉指标。 + func setPanelVisible(_ visible: Bool) { + liveHeadSync.setPanelVisible( + visible, + onHeadTick: { [weak self] in + await self?.refreshEventsHead() + }, + onMetricsTick: { [weak self] in + await self?.syncFromServer(initialLoad: false, paced: false) + } + ) + if visible { + Task { await refreshEventsHead() } + } + } + /// 用户手动触发同步;UI 仍只在数据变化时更新 func refresh() async { await refreshEventsHead() - await syncFromServer(initialLoad: false) + await syncFromServer(initialLoad: false, paced: false) await awaitChartSync(initialLoad: false, force: true) } @@ -199,7 +216,7 @@ final class UsageViewModel: ObservableObject { let targetPage = max(1, min(page, dashboard.usageEventsTotalPages)) guard targetPage != dashboard.usageEventsPage, !isLoadingUsageEventsPage else { return } - if let pageData = cachedUsageEventsPage( + if let pageData = eventsCache.page( startMs: startMs, endMs: endMs, page: targetPage, @@ -244,14 +261,24 @@ final class UsageViewModel: ObservableObject { backgroundSyncTask = Task { [weak self] in guard let self else { return } - await self.syncFromServer(initialLoad: self.dashboard.summary == nil) + await self.syncFromServer(initialLoad: self.dashboard.summary == nil, paced: false) await self.awaitChartSync(initialLoad: true, force: false) self.isReadyForChangeNotifications = true while !Task.isCancelled { - await RequestPacer.shared.sleepUntilNextCycle() + if self.liveHeadSync.panelVisible { + // 面板打开时由 live 轮询负责头/指标;后台只补全量图表 + try? await Task.sleep(nanoseconds: UsageLiveHeadSync.headPollIntervalNanoseconds) + guard !Task.isCancelled else { break } + if self.dashboard.dailySpend.isEmpty + || self.isUsageEventsCacheStale(expectedTotal: self.dashboard.totalEventCount) { + await self.awaitChartSync(initialLoad: false, force: false) + } + continue + } + + await self.syncFromServer(initialLoad: false, paced: true) guard !Task.isCancelled else { break } - await self.syncFromServer(initialLoad: false) if self.dashboard.dailySpend.isEmpty || self.isUsageEventsCacheStale(expectedTotal: self.dashboard.totalEventCount) { await self.awaitChartSync(initialLoad: false, force: false) @@ -271,7 +298,7 @@ final class UsageViewModel: ObservableObject { do { try await apiClient.revokeAuthSession(token: token, sessionId: session.sessionId) - await syncFromServer(initialLoad: false) + await syncFromServer(initialLoad: false, paced: false) } catch { let language = LocalizationManager.shared.resolved errorMessage = (error as? CursorAPIError)?.message(language: language) ?? error.localizedDescription @@ -377,7 +404,8 @@ final class UsageViewModel: ObservableObject { ) } - /// 日筛选时的当日统计;未筛选时用「今天」 + /// 「今日」与日筛选统一从合并后的事件即时计算。 + /// 勿用 `dashboard.todayStats`:它只在全量图表同步后写入,live 拉头时不会更新。 var displayedDayStats: TodayUsageStats { if let selectedDay { return UsageAnalytics.dayStats( @@ -386,6 +414,12 @@ final class UsageViewModel: ObservableObject { billing: dashboard.usageLimit ) } + if !canonicalUsageEvents.isEmpty { + return UsageAnalytics.todayStats( + from: canonicalUsageEvents, + billing: dashboard.usageLimit + ) + } return dashboard.todayStats } @@ -442,7 +476,7 @@ final class UsageViewModel: ObservableObject { ) } - private func syncFromServer(initialLoad: Bool) async { + private func syncFromServer(initialLoad: Bool, paced: Bool) async { guard let token = KeychainService.loadToken() else { hasToken = false errorMessage = CursorAPIError.missingToken.message( @@ -455,7 +489,16 @@ final class UsageViewModel: ObservableObject { isInitialLoading = true } - let snapshot = await apiClient.fetchDashboardData(token: token) + let snapshot: DashboardSnapshot + if paced { + snapshot = await RequestPacer.shared.withSyncWave { + await apiClient.fetchDashboardData(token: token) + } + } else { + snapshot = await RequestPacer.shared.withBurst { + await apiClient.fetchDashboardData(token: token) + } + } applyMetricsIfChanged(snapshot, initialLoad: initialLoad) if initialLoad { @@ -509,10 +552,7 @@ final class UsageViewModel: ObservableObject { if billingCycleChanged, let oldStart = dashboard.billingCycleStartMs, let oldEnd = dashboard.billingCycleEndMs { - UsageEventsCache.clear(startMs: oldStart, endMs: oldEnd) - if memoryUsageEventsCache?.key == usageEventsCacheKey(startMs: oldStart, endMs: oldEnd) { - memoryUsageEventsCache = nil - } + eventsCache.clear(startMs: oldStart, endMs: oldEnd) } let previous = dashboard @@ -603,7 +643,7 @@ final class UsageViewModel: ObservableObject { let updated = dashboard.withChartData(from: result.events) if !force, updated.chartFingerprint == chartFingerprint { return } - persistUsageEventsCache( + eventsCache.save( events: result.events, totalCount: result.totalCount, startMs: startMs, @@ -677,7 +717,7 @@ final class UsageViewModel: ObservableObject { var cachedUsageEvents: [UsageEvent]? { guard let startMs = dashboard.billingCycleStartMs, let endMs = dashboard.billingCycleEndMs, - let store = loadUsageEventsStore(startMs: startMs, endMs: endMs), + let store = eventsCache.load(startMs: startMs, endMs: endMs), !store.events.isEmpty else { return nil } return store.events @@ -694,80 +734,11 @@ final class UsageViewModel: ObservableObject { return date.formatted(date: .abbreviated, time: .omitted) } - private func usageEventsCacheKey(startMs: String, endMs: String) -> String { - "\(startMs)|\(endMs)" - } - - private func loadUsageEventsStore(startMs: String, endMs: String) -> UsageEventsCache.Store? { - let key = usageEventsCacheKey(startMs: startMs, endMs: endMs) - if let memoryUsageEventsCache, memoryUsageEventsCache.key == key { - return memoryUsageEventsCache.store - } - guard let store = UsageEventsCache.load(startMs: startMs, endMs: endMs) else { return nil } - memoryUsageEventsCache = (key, store) - return store - } - - private func persistUsageEventsCache( - events: [UsageEvent], - totalCount: Int, - startMs: String, - endMs: String - ) { - UsageEventsCache.save(events: events, totalCount: totalCount, startMs: startMs, endMs: endMs) - memoryUsageEventsCache = ( - usageEventsCacheKey(startMs: startMs, endMs: endMs), - UsageEventsCache.Store( - billingCycleStartMs: startMs, - billingCycleEndMs: endMs, - totalCount: totalCount, - fetchedAt: Date(), - events: events - ) - ) - } - - private func invalidateUsageEventsCache(startMs: String, endMs: String) { - UsageEventsCache.clear(startMs: startMs, endMs: endMs) - if memoryUsageEventsCache?.key == usageEventsCacheKey(startMs: startMs, endMs: endMs) { - memoryUsageEventsCache = nil - } - } - private func isUsageEventsCacheStale(expectedTotal: Int) -> Bool { - guard let startMs = dashboard.billingCycleStartMs, - let endMs = dashboard.billingCycleEndMs, - let store = loadUsageEventsStore(startMs: startMs, endMs: endMs) - else { return true } - return store.events.count < expectedTotal - } - - private func cachedUsageEventsPage( - startMs: String, - endMs: String, - page: Int, - pageSize: Int - ) -> UsageEventsPage? { - guard UsageEventsCache.canServePage( - startMs: startMs, - endMs: endMs, - page: page, - pageSize: pageSize - ) else { return nil } - - if let store = loadUsageEventsStore(startMs: startMs, endMs: endMs) { - return UsageEventsPage.slice( - from: store.events, - totalCount: store.totalCount, - page: page, - pageSize: pageSize - ) - } - return UsageEventsCache.page( - startMs: startMs, - endMs: endMs, - page: page, - pageSize: pageSize + eventsCache.isStale( + startMs: dashboard.billingCycleStartMs, + endMs: dashboard.billingCycleEndMs, + expectedTotal: expectedTotal ) } @@ -782,10 +753,10 @@ final class UsageViewModel: ObservableObject { let endMs = endMs ?? dashboard.billingCycleEndMs else { return } - let existing = loadUsageEventsStore(startMs: startMs, endMs: endMs)?.events ?? [] + let existing = eventsCache.load(startMs: startMs, endMs: endMs)?.events ?? [] let merged = UsageAnalytics.mergeEvents(fresh: events, into: existing) let resolvedTotal = max(totalCount, merged.count, dashboard.totalEventCount) - persistUsageEventsCache( + eventsCache.save( events: merged, totalCount: resolvedTotal, startMs: startMs, diff --git a/Sources/CursorUsageMenuBar/Views/MenuContentView.swift b/Sources/CursorUsageMenuBar/Views/MenuContentView.swift index df98633..1b8f5d3 100644 --- a/Sources/CursorUsageMenuBar/Views/MenuContentView.swift +++ b/Sources/CursorUsageMenuBar/Views/MenuContentView.swift @@ -13,5 +13,7 @@ struct MenuContentView: View { } } .fixedSize(horizontal: true, vertical: true) + .onAppear { viewModel.setPanelVisible(true) } + .onDisappear { viewModel.setPanelVisible(false) } } }