on master help sign in
fix: 6 bugs found in manual audit

1. BalanceResponse: .rounded() uses banker's rounding (round-half-to-even), so 42.5→42 not 43, -2.5→-2 not -3. Fixed to .rounded(.toNearestOrAwayFromZero). Tests expected away-from-zero but the implementation didn't match. 2. refresh(): refreshTask?.cancel() was AFTER the empty-key guard, so clearing the API key didn't cancel an in-flight fetch. The old task would complete and set balance from the previous key. Moved cancel before the guard. 3. saveAPIKey() with empty key: didn't call refresh() or cancel the in-flight task, leaving stale balance and isLoading=true. Now calls refresh() which handles cancellation + clearing. 4. history not cleared on API key change: sparkline and trend mixed data from different accounts. Now clears history in saveAPIKey() and in refresh()'s empty-key branch. 5. savedConfirmation reset task not cancelled on rapid saves: first reset task would hide "✓ Saved" prematurely. Added savedResetTask tracking, same pattern as copiedResetTask. 6. RelativeDateTimeFormatter allocated per access in lastUpdatedText: SwiftUI calls this on every body recompute. Cached as static property.

commitda14573b63c9ab3822a52dc05671e8cbd0376bdd authoriko <mail@iko.soy> dateSat 11 Jul 2026, 16:40 (3 months ago) parent2f7a334
2 files changed+28−11
HyperCreditsMenubar/CreditsChecker.swift+1−1 HyperCreditsMenubar/ViewModel.swift+27−10
HyperCreditsMenubar/CreditsChecker.swift+1−1
@@ -26,7 +26,7 @@ struct BalanceResponse: Decodable, Equatable {
2626 }
2727
2828 let fractional = try container.decode(Double.self, forKey: .balance)
29− guard let rounded = Int(exactly: fractional.rounded()) else {
29+ guard let rounded = Int(exactly: fractional.rounded(.toNearestOrAwayFromZero)) else {
3030 throw DecodingError.dataCorruptedError(
3131 forKey: .balance,
3232 in: container,
HyperCreditsMenubar/ViewModel.swift+27−10
@@ -116,6 +116,9 @@ final class ViewModel: ObservableObject {
116116 /// instead of the first click's reset firing early.
117117 private var copiedResetTask: Task<Void, Never>?
118118
119+ /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
120+ private var savedResetTask: Task<Void, Never>?
121+
119122 // MARK: - History Configuration
120123
121124 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -143,6 +146,13 @@ final class ViewModel: ObservableObject {
143146 return formatter
144147 }()
145148
149+ /// Cached relative time formatter for "Updated Xm ago" text.
150+ private static let relativeFormatter: RelativeDateTimeFormatter = {
151+ let formatter = RelativeDateTimeFormatter()
152+ formatter.unitsStyle = .short
153+ return formatter
154+ }()
155+
146156 /// Formats an integer with thousands grouping separator.
147157 private func formatBalance(_ value: Int) -> String {
148158 Self.balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
@@ -176,9 +186,7 @@ final class ViewModel: ObservableObject {
176186 /// Relative time string for the last successful update, or nil if never updated.
177187 var lastUpdatedText: String? {
178188 guard let lastUpdated = lastUpdated else { return nil }
179− let formatter = RelativeDateTimeFormatter()
180− formatter.unitsStyle = .short
181− return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: Date()))"
189+ return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
182190 }
183191
184192 // MARK: - History
@@ -216,16 +224,18 @@ final class ViewModel: ObservableObject {
216224 /// Refreshes the balance from the API. No-op if no API key is set.
217225 /// Cancels any previously in-flight refresh to prevent races.
218226 func refresh() {
227+ refreshTask?.cancel()
228+
219229 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
220230 guard !key.isEmpty else {
221231 balance = nil
222232 errorMessage = nil
223233 lastUpdated = nil
234+ history.removeAll()
235+ isLoading = false
224236 return
225237 }
226238
227− refreshTask?.cancel()
228−
229239 refreshTask = Task { @MainActor in
230240 isLoading = true
231241 // Don't clear errorMessage here — keep showing the last error
@@ -312,22 +322,29 @@ final class ViewModel: ObservableObject {
312322 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
313323 if key.isEmpty {
314324 KeychainHelper.delete()
315− balance = nil
316− errorMessage = nil
317− lastUpdated = nil
325+ // refresh() with an empty key clears balance, error, history, and
326+ // cancels any in-flight task from the previous key.
327+ refresh()
318328 } else {
319329 let saved = KeychainHelper.save(key)
320330 guard saved else {
321331 errorMessage = "Could not save API key to Keychain. It may be locked."
322332 return
323333 }
334+ // Clear history from any previous key so the sparkline and trend
335+ // don't mix data from different accounts.
336+ history.removeAll()
324337 refresh()
325338 }
326339
327− // Show "✓ Saved" confirmation, auto-reset after 2 seconds
340+ // Show "✓ Saved" confirmation, auto-reset after 2 seconds.
341+ // Cancel any previous reset so rapid Save clicks don't hide the
342+ // confirmation prematurely.
328343 savedConfirmation = true
329− Task { @MainActor in
344+ savedResetTask?.cancel()
345+ savedResetTask = Task { @MainActor in
330346 try? await Task.sleep(nanoseconds: 2_000_000_000)
347+ guard !Task.isCancelled else { return }
331348 savedConfirmation = false
332349 }
333350 }