diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 94bb1c2..79ef9ca 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -100,8 +100,12 @@ final class AppStore { staleInteractivePayloadAgeSeconds != nil } + var hasMissingInteractivePayloadWithoutAttempt: Bool { + cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad + } + var shouldResetInteractiveRefreshPipeline: Bool { - hasStaleLoading || hasStaleInteractivePayload + hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt } var staleInteractivePayloadAgeSeconds: Int? { @@ -149,16 +153,7 @@ final class AppStore { /// all-provider data in parallel so tab strip costs stay in sync with the hero. func switchTo(period: Period) { selectedPeriod = period - switchTask?.cancel() - switchTask = Task { - if selectedProvider == .all { - await refresh(includeOptimize: false, force: true) - } else { - async let main: Void = refresh(includeOptimize: false, force: true) - async let all: Void = refreshQuietly(period: period) - _ = await (main, all) - } - } + startInteractiveSelectionRefresh() } /// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only @@ -166,13 +161,21 @@ final class AppStore { /// in parallel so the tab strip costs stay in sync with the hero. func switchTo(provider: ProviderFilter) { selectedProvider = provider + startInteractiveSelectionRefresh() + } + + private func startInteractiveSelectionRefresh() { switchTask?.cancel() + resetLoadingState() + let period = selectedPeriod + let provider = selectedProvider + lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil switchTask = Task { if provider == .all { - await refresh(includeOptimize: false, force: true) + await refresh(includeOptimize: false, force: true, showLoading: true) } else { - async let main: Void = refresh(includeOptimize: false, force: true) - async let all: Void = refreshQuietly(period: selectedPeriod) + async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true) + async let all: Void = refreshQuietly(period: period) _ = await (main, all) } } diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 7daccb2..36ff798 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -259,10 +259,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { @discardableResult private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool { - if let started = forceRefreshStartedAt, forceRefreshTask != nil { + if forceRefreshTask != nil { + guard let started = forceRefreshStartedAt else { + NSLog("CodeBurn: force refresh task had no start timestamp - clearing") + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshGeneration &+= 1 + store.resetLoadingState() + return true + } let elapsed = now.timeIntervalSince(started) guard elapsed > forceRefreshWatchdogSeconds else { return false } - NSLog("CodeBurn: force refresh stuck for %ds — cancelling and restarting", Int(elapsed)) + NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed)) forceRefreshTask?.cancel() forceRefreshTask = nil forceRefreshStartedAt = nil diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index 0c7fb14..ca8219e 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -60,4 +60,15 @@ struct AppStoreRefreshRecoveryTests { #expect(!store.hasStaleInteractivePayload) #expect(!store.shouldResetInteractiveRefreshPipeline) } + + @Test("missing unattempted payload triggers hard recovery") + func missingUnattemptedPayloadTriggersHardRecovery() { + let store = AppStore() + + #expect(!store.hasCachedData) + #expect(!store.hasAttemptedCurrentKeyLoad) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.hasMissingInteractivePayloadWithoutAttempt) + #expect(store.shouldResetInteractiveRefreshPipeline) + } }