@@ -25,9 +25,14 @@ final class AppStore {
2525 }
2626 var showingAccentPicker : Bool = false
2727 var currency : String = " USD "
28- var isLoading : Bool { loadingCount > 0 }
29- private var loadingCount : Int = 0
30- var lastError : String ?
28+ var isLoading : Bool { loadingCountsByKey. values. contains { $0 > 0 } }
29+ var isCurrentKeyLoading : Bool { loadingCountsByKey [ currentKey, default: 0 ] > 0 }
30+ var hasAttemptedCurrentKeyLoad : Bool { attemptedKeys. contains ( currentKey) }
31+ var lastError : String ? { lastErrorByKey [ currentKey] }
32+ private var loadingCountsByKey : [ PayloadCacheKey : Int ] = [ : ]
33+ private var loadingStartedAtByKey : [ PayloadCacheKey : Date ] = [ : ]
34+ private var attemptedKeys : Set < PayloadCacheKey > = [ ]
35+ private var lastErrorByKey : [ PayloadCacheKey : String ] = [ : ]
3136 var subscription : SubscriptionUsage ?
3237 var subscriptionError : String ?
3338 var subscriptionLoadState : SubscriptionLoadState = ClaudeCredentialStore . isBootstrapCompleted ? . loading : . notBootstrapped
@@ -130,10 +135,51 @@ final class AppStore {
130135 private var inFlightKeys : Set < PayloadCacheKey > = [ ]
131136
132137 func resetLoadingState( ) {
133- loadingCount = 0
138+ loadingCountsByKey. removeAll ( )
139+ loadingStartedAtByKey. removeAll ( )
134140 inFlightKeys. removeAll ( )
135141 }
136142
143+ private let loadingWatchdogSeconds : TimeInterval = 60
144+
145+ @discardableResult
146+ func clearStaleLoadingIfNeeded( ) -> Bool {
147+ let now = Date ( )
148+ let staleEntries = loadingStartedAtByKey. filter {
149+ now. timeIntervalSince ( $0. value) > loadingWatchdogSeconds
150+ }
151+ guard !staleEntries. isEmpty else { return false }
152+
153+ for (key, started) in staleEntries {
154+ NSLog ( " CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing " ,
155+ Int ( now. timeIntervalSince ( started) ) , key. period. rawValue, key. provider. rawValue)
156+ loadingCountsByKey [ key] = nil
157+ loadingStartedAtByKey [ key] = nil
158+ inFlightKeys. remove ( key)
159+ if cache [ key] == nil {
160+ lastErrorByKey [ key] = " Refresh took longer than expected. CodeBurn will keep retrying in the background. "
161+ }
162+ }
163+ return true
164+ }
165+
166+ private func beginLoading( for key: PayloadCacheKey ) {
167+ if loadingCountsByKey [ key, default: 0 ] == 0 {
168+ loadingStartedAtByKey [ key] = Date ( )
169+ }
170+ loadingCountsByKey [ key, default: 0 ] += 1
171+ }
172+
173+ private func finishLoading( for key: PayloadCacheKey ) {
174+ guard let count = loadingCountsByKey [ key] , count > 0 else { return }
175+ if count == 1 {
176+ loadingCountsByKey [ key] = nil
177+ loadingStartedAtByKey [ key] = nil
178+ } else {
179+ loadingCountsByKey [ key] = count - 1
180+ }
181+ }
182+
137183 private func invalidateStaleDayCache( ) {
138184 let formatter = DateFormatter ( )
139185 formatter. dateFormat = " yyyy-MM-dd "
@@ -155,9 +201,11 @@ final class AppStore {
155201 if !force, cache [ key] ? . isFresh == true { return }
156202 if !force, inFlightKeys. contains ( key) { return }
157203 inFlightKeys. insert ( key)
204+ attemptedKeys. insert ( key)
205+ lastErrorByKey [ key] = nil
158206 let didShowLoading = showLoading || cache [ key] == nil
159207 if didShowLoading {
160- loadingCount += 1
208+ beginLoading ( for : key )
161209 }
162210 // Diagnostic anchor: if this key has been empty for a long time (the
163211 // popover would currently be showing "Loading..."), log how stale the
@@ -172,7 +220,9 @@ final class AppStore {
172220 }
173221 defer {
174222 inFlightKeys. remove ( key)
175- if didShowLoading { loadingCount = max ( loadingCount - 1 , 0 ) }
223+ if didShowLoading {
224+ finishLoading ( for: key)
225+ }
176226 }
177227 do {
178228 let fresh = try await DataClient . fetch ( period: key. period, provider: key. provider, includeOptimize: includeOptimize)
@@ -194,7 +244,7 @@ final class AppStore {
194244 }
195245 cache [ key] = CachedPayload ( payload: fresh, fetchedAt: Date ( ) )
196246 lastSuccessByKey [ key] = Date ( )
197- lastError = nil
247+ lastErrorByKey [ key ] = nil
198248 } catch {
199249 if Task . isCancelled { return }
200250 NSLog ( " CodeBurn: fetch failed for \( key. period. rawValue) / \( key. provider. rawValue) : \( error) " )
@@ -205,14 +255,14 @@ final class AppStore {
205255 if cacheDate != cacheDateAtStart { return }
206256 cache [ key] = CachedPayload ( payload: fallback, fetchedAt: Date ( ) )
207257 lastSuccessByKey [ key] = Date ( )
208- lastError = nil
258+ lastErrorByKey [ key ] = nil
209259 return
210260 } catch {
211261 if Task . isCancelled { return }
212262 NSLog ( " CodeBurn: fallback fetch also failed: \( error) " )
213263 }
214264 }
215- lastError = String ( describing: error)
265+ lastErrorByKey [ key ] = String ( describing: error)
216266 }
217267
218268 let allKey = PayloadCacheKey ( period: selectedPeriod, provider: . all)
@@ -232,7 +282,10 @@ final class AppStore {
232282 // Same day-rollover guard as refresh(): drop yesterday's payload if
233283 // the calendar rolled over during the fetch.
234284 if cacheDate != cacheDateAtStart { return }
235- cache [ PayloadCacheKey ( period: period, provider: . all) ] = CachedPayload ( payload: fresh, fetchedAt: Date ( ) )
285+ let key = PayloadCacheKey ( period: period, provider: . all)
286+ cache [ key] = CachedPayload ( payload: fresh, fetchedAt: Date ( ) )
287+ lastSuccessByKey [ key] = Date ( )
288+ lastErrorByKey [ key] = nil
236289 } catch {
237290 NSLog ( " CodeBurn: quiet refresh failed for \( period. rawValue) : \( error) " )
238291 }
0 commit comments