fix(menubar): prefetch periods and align dashboard dates with local timezone

Loading overlay no longer flashes on every 15s poll. isLoading now only
toggles when the cache is cold, and all periods prefetch once on launch
so tab switching is instant.

Heatmap tooltip, trend bars, forecast, and all-time stats were computing
on UTC dates while the CLI reports on local dates, so the two disagreed
at day boundaries. Switched every date formatter and calendar in these
paths to .current so the menubar matches codeburn today output.
This commit is contained in:
iamtoruk 2026-04-20 19:25:14 -07:00
parent 988060cd09
commit 3c2aab2207
3 changed files with 35 additions and 21 deletions

View file

@ -73,10 +73,11 @@ final class AppStore {
let key = currentKey
guard !inFlightKeys.contains(key) else { return }
inFlightKeys.insert(key)
isLoading = true
let showLoading = cache[key] == nil
if showLoading { isLoading = true }
defer {
inFlightKeys.remove(key)
isLoading = false
if showLoading { isLoading = false }
}
do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
@ -88,6 +89,15 @@ final class AppStore {
}
}
/// Prefetch all periods so tab switching is instant. Skips any period already cached.
func prefetchAll() async {
for period in Period.allCases {
let key = PayloadCacheKey(period: period, provider: .all)
if cache[key] != nil { continue }
await refreshQuietly(period: period)
}
}
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
/// Always uses the .all provider since the menubar badge shows total spend.

View file

@ -71,17 +71,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func startRefreshLoop() {
refreshTask = Task { [weak self] in
guard let s = self else { return }
// First cycle: fetch current view, then prefetch all periods in background
await s.store.refreshQuietly(period: .today)
s.refreshStatusButton()
await s.store.refresh(includeOptimize: true)
s.refreshStatusButton()
await s.store.prefetchAll()
while !Task.isCancelled {
guard let self else { return }
// Always keep the (today, all) payload warm. The menubar title and the
// agent tab strip both read from it, so it has to refresh every cycle
// regardless of whether the user is currently viewing Today or a
// different period / provider.
await self.store.refreshQuietly(period: .today)
self.refreshStatusButton()
await self.store.refresh(includeOptimize: true)
self.refreshStatusButton()
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
guard let s = self else { return }
await s.store.refreshQuietly(period: .today)
s.refreshStatusButton()
await s.store.refresh(includeOptimize: true)
s.refreshStatusButton()
}
}
}

View file

@ -344,7 +344,7 @@ private struct BarTooltipCard: View {
private func prettyDate(_ ymd: String) -> String {
let parser = DateFormatter()
parser.dateFormat = "yyyy-MM-dd"
parser.timeZone = TimeZone(identifier: "UTC")
parser.timeZone = .current
guard let date = parser.date(from: ymd) else { return ymd }
let display = DateFormatter()
display.dateFormat = "EEE MMM d"
@ -392,11 +392,11 @@ private struct TrendStats {
private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
calendar.timeZone = .current
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
f.timeZone = .current
return f
}()
let entryByDate = Dictionary(uniqueKeysWithValues: days.map { ($0.date, $0) })
@ -427,11 +427,11 @@ private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry]) -
let peak = bars.filter { $0.cost > 0 }.max(by: { $0.cost < $1.cost })
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
calendar.timeZone = .current
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
f.timeZone = .current
return f
}()
let today = calendar.startOfDay(for: Date())
@ -547,11 +547,11 @@ private struct ForecastStats {
private func computeForecast(days: [DailyHistoryEntry]) -> ForecastStats {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
calendar.timeZone = .current
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
f.timeZone = .current
return f
}()
let now = Date()
@ -798,17 +798,17 @@ private func computeAllStats(payload: MenubarPayload) -> AllStats {
let favoriteModel = payload.current.topModels.first?.name ?? ""
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
calendar.timeZone = .current
let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
f.timeZone = .current
return f
}()
let displayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM d"
f.timeZone = TimeZone(identifier: "UTC")
f.timeZone = .current
return f
}()