@@ -25,10 +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- private var loadingStartedAt : Date ?
31- 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 ] = [ : ]
3236 var subscription : SubscriptionUsage ?
3337 var subscriptionError : String ?
3438 var subscriptionLoadState : SubscriptionLoadState = ClaudeCredentialStore . isBootstrapCompleted ? . loading : . notBootstrapped
@@ -131,22 +135,51 @@ final class AppStore {
131135 private var inFlightKeys : Set < PayloadCacheKey > = [ ]
132136
133137 func resetLoadingState( ) {
134- loadingCount = 0
135- loadingStartedAt = nil
138+ loadingCountsByKey . removeAll ( )
139+ loadingStartedAtByKey . removeAll ( )
136140 inFlightKeys. removeAll ( )
137141 }
138142
139143 private let loadingWatchdogSeconds : TimeInterval = 60
140144
141145 @discardableResult
142146 func clearStaleLoadingIfNeeded( ) -> Bool {
143- guard isLoading, let started = loadingStartedAt,
144- Date ( ) . timeIntervalSince ( started) > loadingWatchdogSeconds else { return false }
145- NSLog ( " CodeBurn: loading stuck for %ds — auto-clearing " , Int ( Date ( ) . timeIntervalSince ( started) ) )
146- resetLoadingState ( )
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+ }
147163 return true
148164 }
149165
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+
150183 private func invalidateStaleDayCache( ) {
151184 let formatter = DateFormatter ( )
152185 formatter. dateFormat = " yyyy-MM-dd "
@@ -168,10 +201,11 @@ final class AppStore {
168201 if !force, cache [ key] ? . isFresh == true { return }
169202 if !force, inFlightKeys. contains ( key) { return }
170203 inFlightKeys. insert ( key)
204+ attemptedKeys. insert ( key)
205+ lastErrorByKey [ key] = nil
171206 let didShowLoading = showLoading || cache [ key] == nil
172207 if didShowLoading {
173- if loadingCount == 0 { loadingStartedAt = Date ( ) }
174- loadingCount += 1
208+ beginLoading ( for: key)
175209 }
176210 // Diagnostic anchor: if this key has been empty for a long time (the
177211 // popover would currently be showing "Loading..."), log how stale the
@@ -187,8 +221,7 @@ final class AppStore {
187221 defer {
188222 inFlightKeys. remove ( key)
189223 if didShowLoading {
190- loadingCount = max ( loadingCount - 1 , 0 )
191- if loadingCount == 0 { loadingStartedAt = nil }
224+ finishLoading ( for: key)
192225 }
193226 }
194227 do {
@@ -211,7 +244,7 @@ final class AppStore {
211244 }
212245 cache [ key] = CachedPayload ( payload: fresh, fetchedAt: Date ( ) )
213246 lastSuccessByKey [ key] = Date ( )
214- lastError = nil
247+ lastErrorByKey [ key ] = nil
215248 } catch {
216249 if Task . isCancelled { return }
217250 NSLog ( " CodeBurn: fetch failed for \( key. period. rawValue) / \( key. provider. rawValue) : \( error) " )
@@ -222,14 +255,14 @@ final class AppStore {
222255 if cacheDate != cacheDateAtStart { return }
223256 cache [ key] = CachedPayload ( payload: fallback, fetchedAt: Date ( ) )
224257 lastSuccessByKey [ key] = Date ( )
225- lastError = nil
258+ lastErrorByKey [ key ] = nil
226259 return
227260 } catch {
228261 if Task . isCancelled { return }
229262 NSLog ( " CodeBurn: fallback fetch also failed: \( error) " )
230263 }
231264 }
232- lastError = String ( describing: error)
265+ lastErrorByKey [ key ] = String ( describing: error)
233266 }
234267
235268 let allKey = PayloadCacheKey ( period: selectedPeriod, provider: . all)
@@ -249,7 +282,10 @@ final class AppStore {
249282 // Same day-rollover guard as refresh(): drop yesterday's payload if
250283 // the calendar rolled over during the fetch.
251284 if cacheDate != cacheDateAtStart { return }
252- 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
253289 } catch {
254290 NSLog ( " CodeBurn: quiet refresh failed for \( period. rawValue) : \( error) " )
255291 }
0 commit comments