Fix stale menubar data after sleep and silent refresh button

Cache now tracks the calendar date and clears on day rollover so
overnight sleep no longer shows yesterday's numbers. Wake-from-sleep
invalidates the entire cache before fetching. Manual refresh and wake
explicitly request loading feedback so the spinner is visible even
when stale data exists.
This commit is contained in:
iamtoruk 2026-05-05 11:35:38 -07:00
parent 474c71a77b
commit 18c3c8f908
4 changed files with 26 additions and 6 deletions

View file

@ -19,6 +19,8 @@
### Fixed (macOS menubar) ### Fixed (macOS menubar)
- **Stuck loading spinner.** The menubar ran `--optimize` on every 30-second background refresh. As sessions accumulated, optimize exceeded the 45-second timeout, and the loading overlay stayed forever with no fallback. Optimize is now stripped from all menubar fetches (use `codeburn optimize` in the CLI instead). On fetch failure with empty cache, the app retries without optimize so the spinner always clears. - **Stuck loading spinner.** The menubar ran `--optimize` on every 30-second background refresh. As sessions accumulated, optimize exceeded the 45-second timeout, and the loading overlay stayed forever with no fallback. Optimize is now stripped from all menubar fetches (use `codeburn optimize` in the CLI instead). On fetch failure with empty cache, the app retries without optimize so the spinner always clears.
- **Stale data after overnight sleep.** Cache keys used the period enum (`.today`) not a calendar date, so data from yesterday persisted after midnight. Cache now tracks the current date and clears itself on day rollover. Wake-from-sleep additionally clears all cached entries before fetching fresh data.
- **Refresh button appeared to do nothing.** Clicking refresh with stale cached data never showed the loading overlay because loading state only triggered on empty cache. Manual refresh and wake-from-sleep now explicitly request loading feedback.
## 0.9.6 - 2026-05-03 ## 0.9.6 - 2026-05-03

View file

@ -34,6 +34,7 @@ final class AppStore {
var capacityEstimates: [String: CapacityEstimate] = [:] var capacityEstimates: [String: CapacityEstimate] = [:]
private var cache: [PayloadCacheKey: CachedPayload] = [:] private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
private var switchTask: Task<Void, Never>? private var switchTask: Task<Void, Never>?
private var currentKey: PayloadCacheKey { private var currentKey: PayloadCacheKey {
@ -99,18 +100,33 @@ final class AppStore {
private var inFlightKeys: Set<PayloadCacheKey> = [] private var inFlightKeys: Set<PayloadCacheKey> = []
func refresh(includeOptimize: Bool, force: Bool = false) async { private func invalidateStaleDayCache() {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let today = formatter.string(from: Date())
if cacheDate != today {
cache.removeAll()
cacheDate = today
}
}
func invalidateCache() {
cache.removeAll()
}
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
invalidateStaleDayCache()
let key = currentKey let key = currentKey
if !force, cache[key]?.isFresh == true { return } if !force, cache[key]?.isFresh == true { return }
if !force, inFlightKeys.contains(key) { return } if !force, inFlightKeys.contains(key) { return }
inFlightKeys.insert(key) inFlightKeys.insert(key)
let showedLoading = cache[key] == nil let didShowLoading = showLoading || cache[key] == nil
if showedLoading { if didShowLoading {
loadingCount += 1 loadingCount += 1
} }
defer { defer {
inFlightKeys.remove(key) inFlightKeys.remove(key)
if showedLoading { loadingCount = max(loadingCount - 1, 0) } if didShowLoading { loadingCount = max(loadingCount - 1, 0) }
} }
do { do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize) let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
@ -145,6 +161,7 @@ final class AppStore {
/// Does not toggle isLoading, so the popover's loading overlay is unaffected. /// Does not toggle isLoading, so the popover's loading overlay is unaffected.
/// Always uses the .all provider since the menubar badge shows total spend. /// Always uses the .all provider since the menubar badge shows total spend.
func refreshQuietly(period: Period) async { func refreshQuietly(period: Period) async {
invalidateStaleDayCache()
do { do {
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false) let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date()) cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())

View file

@ -170,8 +170,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
guard now.timeIntervalSince(lastRefreshTime) > 5 else { return } guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
lastRefreshTime = now lastRefreshTime = now
store.invalidateCache()
Task { Task {
async let main: Void = store.refresh(includeOptimize: false, force: true) async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
async let today: Void = store.refreshQuietly(period: .today) async let today: Void = store.refreshQuietly(period: .today)
_ = await (main, today) _ = await (main, today)
refreshStatusButton() refreshStatusButton()

View file

@ -407,7 +407,7 @@ struct FooterBar: View {
.fixedSize() .fixedSize()
Button { Button {
Task { await store.refresh(includeOptimize: false, force: true) } Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
} label: { } label: {
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise") Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))