Skip to content

Commit cd0b4de

Browse files
authored
perf: breathing timer optimization, chart caching, account switch UX (#114)
- Breathing animation: red band (≥95%) now ticks every 1s (step by 2) instead of 500ms — halves CPU wakeups with imperceptible visual change. Sparkle mode keeps 500ms for smooth twinkling. Timer auto-restarts on mode transitions. - Chart data caching: per-mode fingerprint tracks whether underlying data changed. Toggling 24H→7D→24H skips recomputation if data is unchanged. - Account switch: sets isLoading=true immediately so the spinner appears during the refresh instead of showing stale data briefly. - Specs updated (CONSTANTS.md, UI_SPEC.md)
1 parent ecdf296 commit cd0b4de

File tree

5 files changed

+36
-10
lines changed

5 files changed

+36
-10
lines changed

AIBattery/ViewModels/UsageViewModel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ public final class UsageViewModel: ObservableObject {
172172
isShowingCachedData = false
173173
lastFreshFetch = nil
174174
errorMessage = nil
175+
isLoading = true
175176
OAuthManager.shared.objectWillChange.send()
176177
Task { await refresh() }
177178
}

AIBattery/Views/ActivityChartView.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,35 @@ struct ActivityChartView: View {
3838
@State private var cachedMonthly: [ActivityChartData.MonthlyPoint] = []
3939
@State private var cachedMonthTotals: [String: Int] = [:]
4040

41+
/// Per-mode fingerprint to skip recomputation when toggling back to a mode
42+
/// whose underlying data hasn't changed (e.g. 24H → 7D → 24H).
43+
@State private var lastHourlyFingerprint: Int = 0
44+
@State private var lastDailyFingerprint: Int = 0
45+
@State private var lastMonthlyFingerprint: Int = 0
46+
4147
/// Recompute cached data for the active mode only. Called from body via .onChange.
4248
private func refreshCachedData() {
43-
ensureCachedData(for: mode)
49+
ensureCachedData(for: mode, force: true)
4450
}
4551

4652
/// Lazily compute cached data only for the requested mode.
47-
private func ensureCachedData(for chartMode: ActivityChartMode) {
53+
/// Skips recomputation if the underlying data fingerprint hasn't changed.
54+
private func ensureCachedData(for chartMode: ActivityChartMode, force: Bool = false) {
55+
let fp = dataFingerprint
4856
switch chartMode {
4957
case .hourly:
58+
guard force || fp != lastHourlyFingerprint else { return }
5059
cachedHourly = ActivityChartData.hourlyData(from: todayHourCounts)
60+
lastHourlyFingerprint = fp
5161
case .daily:
62+
guard force || fp != lastDailyFingerprint else { return }
5263
cachedDaily = ActivityChartData.dailyData(from: dailyActivity)
64+
lastDailyFingerprint = fp
5365
case .monthly:
66+
guard force || fp != lastMonthlyFingerprint else { return }
5467
cachedMonthly = ActivityChartData.monthlyData(from: dailyActivity)
5568
cachedMonthTotals = ActivityChartData.monthTotals(from: dailyActivity)
69+
lastMonthlyFingerprint = fp
5670
}
5771
}
5872

AIBattery/Views/StatusBarManager.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,21 @@ public final class StatusBarManager: NSObject {
309309

310310
// MARK: - Breath timer
311311

312-
/// Breathing cycle: 4s per full cycle, 8 discrete steps (500ms per tick).
313-
/// Always runs. Pauses on screen sleep.
312+
/// Breathing cycle: 4s per full cycle, discrete steps.
313+
/// Sparkle mode: 8 steps (500ms per tick) for smooth twinkling.
314+
/// Red band (≥95%): 4 steps (1s per tick) — halves CPU wakeups with imperceptible visual change.
315+
/// Pauses on screen sleep.
316+
private var breathTimerStepSize: Int = 1
317+
314318
private func startBreathTimerIfNeeded() {
319+
// Red band uses every-other step (4 wakeups/cycle); sparkle uses every step (8 wakeups/cycle).
320+
let newStepSize = isSparkleActive ? 1 : 2
321+
// Restart timer if step size changed (e.g. sparkle → red transition)
322+
if breathTimer != nil && breathTimerStepSize != newStepSize {
323+
stopBreathTimer()
324+
}
315325
guard breathTimer == nil else { return }
326+
breathTimerStepSize = newStepSize
316327

317328
// Observe screen sleep/wake to pause animation when display is off
318329
if screenSleepObserver == nil {
@@ -330,11 +341,11 @@ public final class StatusBarManager: NSObject {
330341
}
331342
}
332343

333-
let interval: TimeInterval = 4.0 / Double(MenuBarIcon.pulseSteps)
344+
let interval: TimeInterval = 4.0 / Double(MenuBarIcon.pulseSteps) * Double(newStepSize)
334345
breathTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
335346
MainActor.assumeIsolated {
336347
guard let self, let button = self.statusItem?.button else { return }
337-
self.currentPulseStep = (self.currentPulseStep + 1) % MenuBarIcon.pulseSteps
348+
self.currentPulseStep = (self.currentPulseStep + self.breathTimerStepSize) % MenuBarIcon.pulseSteps
338349
button.image = MenuBarIcon.statusBarImage(
339350
for: self.currentPercent,
340351
color: self.currentColor,

spec/CONSTANTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,8 @@ Pricing per million tokens:
233233
| Recovery sparkle duration | 30 sec |
234234
| Pulse steps per cycle | 8 |
235235
| Pulse cycle duration | 4.0 sec |
236-
| Pulse tick interval | 500ms (4s ÷ 8) |
237-
| Breath timer threshold | ≥95% usage, throttled, or sparkle active (orange 80–95% uses static glow, no timer) |
236+
| Pulse tick interval | 500ms sparkle (4s ÷ 8), 1s red band (4s ÷ 8 × 2-step) |
237+
| Breath timer threshold | ≥95% usage or sparkle active (orange 80–95% uses static glow, no timer; throttled stops timer) |
238238
| Burst ray count | 12 |
239239
| Burst ray half-angle | 0.08 radians |
240240
| Health dot size | 8pt |

spec/UI_SPEC.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ X-axis per mode:
307307
- **7D**: Rolling 7-day window. Day abbreviation (`.system(size: 9)`) for all days including today
308308
- **12M**: Rolling 12-month window. 3-letter month (`"MMM"` → Jan, Feb, etc.), `.system(size: 9)`
309309

310-
Data per mode:
310+
Data per mode (cached per-mode with fingerprint — toggling back to a mode skips recomputation if underlying data unchanged):
311311
- **24H**: `todayHourCounts` trailing 24 hours (`(currentHour - 23)` through `currentHour`, wrapping via `% 24`)
312312
- **7D**: `dailyActivity` last 7 days (rolling window) → daily message counts
313313
- **12M**: `dailyActivity` grouped by year-month, summed, rolling 12-month window. Current month projected to full-month pace (`total * daysInMonth / dayOfMonth`) for fair comparison.
@@ -436,7 +436,7 @@ This ensures the user sees actionable "2h 15m" instead of a stuck "100%" when ca
436436
- Triggered by `StatusBarManager` detecting `isThrottled` going from true → false
437437
- Automatically stops after 30 seconds, returning to normal breathing mode
438438

439-
**Animation**: `StatusBarManager` runs a repeating timer (4s full cycle, 8 discrete steps, 500ms per tick).
439+
**Animation**: `StatusBarManager` runs a repeating timer (4s full cycle, 8 discrete steps). Sparkle mode ticks every 500ms (all 8 steps). Red band (≥95%) ticks every 1s (steps by 2) — halves CPU wakeups with imperceptible visual change. Timer restarts automatically when transitioning between sparkle and red band modes.
440440
- Active only when visually impactful: sparkle active or critical usage (≥95%). Throttled uses static broken star — no timer. Orange band (80–95%) uses static glow — no timer. Below 80%, breathing is imperceptible — timer stopped to save wake-ups.
441441
- Recovery sparkle overlaid for 30s after throttle clears
442442
- Pauses on screen sleep, resumes on wake

0 commit comments

Comments
 (0)