Add multi-day calendar selection with custom popover UI (#393)

* Add multi-day calendar selection with custom popover UI

Replace native DatePicker with a custom-themed CalendarPopover supporting
non-contiguous multi-day selection. Add --days CLI flag for comma-separated
date filtering that post-filters from the bounding range scan.

CLI: parseDaysFlag() parses and validates dates, filterProjectsByDays()
filters turns to only selected days. Verified that sum of individual --day
calls matches --days output across cost, calls, activities, models, and
providers.

Swift: DataClient passes --days for multi-day, with single-day fallback.
AppStore tracks selectedDays as Set<String> with PayloadCacheKey support.
CalendarPopover uses pending state committed only on Done tap.

* Clear + Done in calendar resets to current period

When user clears all selected days and taps Done, switch back to the
active period instead of silently keeping the old day selection.
This commit is contained in:
Resham Joshi 2026-05-24 09:49:59 -07:00 committed by GitHub
parent aa69857e30
commit 5800179da9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 859 additions and 316 deletions

View file

@ -14,6 +14,22 @@ struct CachedPayload {
struct PayloadCacheKey: Hashable { struct PayloadCacheKey: Hashable {
let period: Period let period: Period
let provider: ProviderFilter let provider: ProviderFilter
let day: String?
let days: Set<String>
init(period: Period, provider: ProviderFilter, day: String? = nil, days: Set<String> = []) {
self.period = period
self.provider = provider
self.day = days.count <= 1 ? (day ?? days.first) : nil
self.days = days.count > 1 ? days : []
}
var label: String {
if !days.isEmpty, let first = days.min(), let last = days.max() {
return "\(first)..\(last)"
}
return day.map { "Day(\($0))" } ?? period.rawValue
}
} }
@MainActor @MainActor
@ -21,6 +37,12 @@ struct PayloadCacheKey: Hashable {
final class AppStore { final class AppStore {
var selectedProvider: ProviderFilter = .all var selectedProvider: ProviderFilter = .all
var selectedPeriod: Period = .today var selectedPeriod: Period = .today
var selectedDays: Set<String> = []
var selectedDay: String? {
guard selectedDays.count == 1 else { return selectedDays.min() }
return selectedDays.first
}
private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() { private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() {
didSet { menubarPeriod.persistAsMenubarDefault() } didSet { menubarPeriod.persistAsMenubarDefault() }
} }
@ -48,7 +70,6 @@ final class AppStore {
var subscriptionError: String? var subscriptionError: String?
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
var capacityEstimates: [String: CapacityEstimate] = [:] var capacityEstimates: [String: CapacityEstimate] = [:]
var refreshPauseMessage: String?
var codexUsage: CodexUsage? var codexUsage: CodexUsage?
var codexError: String? var codexError: String?
@ -71,21 +92,32 @@ final class AppStore {
/// from "cache was wiped 10 minutes ago and we still haven't refilled". /// from "cache was wiped 10 minutes ago and we still haven't refilled".
private var lastSuccessByKey: [PayloadCacheKey: Date] = [:] private var lastSuccessByKey: [PayloadCacheKey: Date] = [:]
static let dayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = .current
return formatter
}()
static func dayString(from date: Date) -> String {
dayFormatter.string(from: date)
}
private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval { private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval {
guard let last = lastSuccessByKey[key] else { return .infinity } guard let last = lastSuccessByKey[key] else { return .infinity }
return Date().timeIntervalSince(last) return Date().timeIntervalSince(last)
} }
private var todayAllKey: PayloadCacheKey { private var todayAllKey: PayloadCacheKey {
PayloadCacheKey(period: .today, provider: .all) PayloadCacheKey(period: .today, provider: .all, day: nil)
} }
private var menubarStatusKey: PayloadCacheKey { private var menubarStatusKey: PayloadCacheKey {
PayloadCacheKey(period: menubarPeriod, provider: .all) PayloadCacheKey(period: menubarPeriod, provider: .all, day: nil)
} }
private var currentKey: PayloadCacheKey { private var currentKey: PayloadCacheKey {
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider) PayloadCacheKey(period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
} }
var payload: MenubarPayload { var payload: MenubarPayload {
@ -111,8 +143,6 @@ final class AppStore {
cache[menubarStatusKey]?.isFresh != true cache[menubarStatusKey]?.isFresh != true
} }
/// All-provider payload for the user-selected menubar status metric. The
/// popover's visible period/provider can differ from this setting.
var menubarPayload: MenubarPayload? { var menubarPayload: MenubarPayload? {
cache[menubarStatusKey]?.payload cache[menubarStatusKey]?.payload
} }
@ -120,7 +150,22 @@ final class AppStore {
/// All-provider payload for the selected period. Used by the tab strip to show /// All-provider payload for the selected period. Used by the tab strip to show
/// per-provider costs that match the active period, not just today. /// per-provider costs that match the active period, not just today.
var periodAllPayload: MenubarPayload? { var periodAllPayload: MenubarPayload? {
cache[PayloadCacheKey(period: selectedPeriod, provider: .all)]?.payload cache[PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)]?.payload
}
var isDayMode: Bool {
!selectedDays.isEmpty
}
var selectionLabel: String {
if selectedDays.count > 1, let first = selectedDays.min(), let last = selectedDays.max() {
return "\(selectedDays.count) days (\(first) .. \(last))"
}
return selectedDay.map { "Day (\($0))" } ?? selectedPeriod.rawValue
}
var trendPeriod: Period {
isDayMode ? .today : selectedPeriod
} }
var hasCachedData: Bool { var hasCachedData: Bool {
@ -150,7 +195,7 @@ final class AppStore {
let keys = Set([ let keys = Set([
currentKey, currentKey,
todayAllKey, todayAllKey,
PayloadCacheKey(period: selectedPeriod, provider: .all), PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays),
]) ])
let staleAges = keys.compactMap { key -> TimeInterval? in let staleAges = keys.compactMap { key -> TimeInterval? in
guard let cached = cache[key] else { return nil } guard let cached = cache[key] else { return nil }
@ -161,7 +206,7 @@ final class AppStore {
} }
var needsInteractivePayloadRefresh: Bool { var needsInteractivePayloadRefresh: Bool {
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all) let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)
return cache[currentKey]?.isFresh != true || return cache[currentKey]?.isFresh != true ||
cache[todayAllKey]?.isFresh != true || cache[todayAllKey]?.isFresh != true ||
cache[periodAllKey]?.isFresh != true || cache[periodAllKey]?.isFresh != true ||
@ -177,8 +222,8 @@ final class AppStore {
} }
#if DEBUG #if DEBUG
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) { func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, day: String? = nil, fetchedAt: Date) {
cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) cache[PayloadCacheKey(period: period, provider: provider, day: day)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
} }
#endif #endif
@ -190,9 +235,51 @@ final class AppStore {
/// all-provider data in parallel so tab strip costs stay in sync with the hero. /// all-provider data in parallel so tab strip costs stay in sync with the hero.
func switchTo(period: Period) { func switchTo(period: Period) {
selectedPeriod = period selectedPeriod = period
selectedDays = []
startInteractiveSelectionRefresh() startInteractiveSelectionRefresh()
} }
func switchToYesterday() {
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
switchTo(day: yesterday)
}
func switchTo(day: Date) {
let clamped = min(Calendar.current.startOfDay(for: day), Calendar.current.startOfDay(for: Date()))
selectedDays = [Self.dayString(from: clamped)]
startInteractiveSelectionRefresh()
}
func switchTo(days: Set<String>) {
selectedDays = days
startInteractiveSelectionRefresh()
}
func shiftSelectedDay(by delta: Int) {
let base = selectedDayDate ?? Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
let shifted = Calendar.current.date(byAdding: .day, value: delta, to: base) ?? base
switchTo(day: shifted)
}
var selectedDayDate: Date? {
guard let selectedDay else { return nil }
return Self.dayFormatter.date(from: selectedDay)
}
var canShiftSelectedDayForward: Bool {
guard let selectedDayDate else { return false }
return Calendar.current.startOfDay(for: selectedDayDate) < Calendar.current.startOfDay(for: Date())
}
func setMenubarPeriod(_ period: Period) {
guard Period.menubarMetricCases.contains(period) else { return }
guard menubarPeriod != period else { return }
menubarPeriod = period
Task { [weak self] in
await self?.refreshQuietly(period: period)
}
}
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only /// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data /// runs the CLI for the final selection. Fetches provider-specific and all-provider data
/// in parallel so the tab strip costs stay in sync with the hero. /// in parallel so the tab strip costs stay in sync with the hero.
@ -206,27 +293,20 @@ final class AppStore {
resetLoadingState() resetLoadingState()
let period = selectedPeriod let period = selectedPeriod
let provider = selectedProvider let provider = selectedProvider
lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil let day = selectedDay
let key = PayloadCacheKey(period: period, provider: provider, day: day)
lastErrorByKey[key] = nil
switchTask = Task { switchTask = Task {
if provider == .all { if provider == .all {
await refresh(includeOptimize: false, force: true, showLoading: true) await refresh(key: key, includeOptimize: false, force: true, showLoading: true)
} else { } else {
async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true) async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true)
async let all: Void = refreshQuietly(period: period) async let all: Void = refreshQuietly(period: period, day: day)
_ = await (main, all) _ = await (main, all)
} }
} }
} }
func setMenubarPeriod(_ period: Period) {
guard Period.menubarMetricCases.contains(period) else { return }
guard menubarPeriod != period else { return }
menubarPeriod = period
Task { [weak self] in
await self?.refreshQuietly(period: period)
}
}
private var inFlightKeys: Set<PayloadCacheKey> = [] private var inFlightKeys: Set<PayloadCacheKey> = []
func resetLoadingState() { func resetLoadingState() {
@ -234,6 +314,7 @@ final class AppStore {
loadingCountsByKey.removeAll() loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll() loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll() inFlightKeys.removeAll()
attemptedKeys.removeAll()
} }
func resetRefreshState(clearCache: Bool = false) { func resetRefreshState(clearCache: Bool = false) {
@ -247,17 +328,6 @@ final class AppStore {
} }
} }
func pauseAutomaticRefresh(until: Date, consecutiveStalls: Int) {
let formatter = DateFormatter()
formatter.timeStyle = .short
formatter.dateStyle = .none
refreshPauseMessage = "Refresh paused until \(formatter.string(from: until)) after \(consecutiveStalls) stalled attempts. Click retry to resume."
}
func clearRefreshPause() {
refreshPauseMessage = nil
}
private let loadingWatchdogSeconds: TimeInterval = 60 private let loadingWatchdogSeconds: TimeInterval = 60
@discardableResult @discardableResult
@ -271,7 +341,7 @@ final class AppStore {
payloadRefreshGeneration &+= 1 payloadRefreshGeneration &+= 1
for (key, started) in staleEntries { for (key, started) in staleEntries {
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing", NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue) Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue)
loadingCountsByKey[key] = nil loadingCountsByKey[key] = nil
loadingStartedAtByKey[key] = nil loadingStartedAtByKey[key] = nil
inFlightKeys.remove(key) inFlightKeys.remove(key)
@ -325,10 +395,14 @@ final class AppStore {
} }
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
}
private func refresh(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
invalidateStaleDayCache() invalidateStaleDayCache()
let key = currentKey
let cacheDateAtStart = cacheDate let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration let generationAtStart = payloadRefreshGeneration
if Task.isCancelled { return }
if !force, cache[key]?.isFresh == true { return } if !force, cache[key]?.isFresh == true { return }
if inFlightKeys.contains(key) { return } if inFlightKeys.contains(key) { return }
inFlightKeys.insert(key) inFlightKeys.insert(key)
@ -347,18 +421,22 @@ final class AppStore {
// below filters .infinity) that's just the cold path, not a bug. // below filters .infinity) that's just the cold path, not a bug.
let staleSeconds = staleSecondsForKey(key) let staleSeconds = staleSecondsForKey(key)
if staleSeconds.isFinite, staleSeconds > 120 { if staleSeconds.isFinite, staleSeconds > 120 {
NSLog("CodeBurn: refresh attempt for stale key \(key.period.rawValue)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago") NSLog("CodeBurn: refresh attempt for stale key \(key.label)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago")
} }
defer { defer {
let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration
inFlightKeys.remove(key) inFlightKeys.remove(key)
if didShowLoading { if didShowLoading {
finishLoading(for: key) finishLoading(for: key)
} }
if abandonedAttempt && cache[key] == nil && lastErrorByKey[key] == nil {
attemptedKeys.remove(key)
}
} }
do { do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize) let fresh = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: includeOptimize)
if generationAtStart != payloadRefreshGeneration { if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch") NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
return return
} }
if Task.isCancelled { if Task.isCancelled {
@ -366,7 +444,7 @@ final class AppStore {
// the silent-no-result path. Without this log, a cancelled // the silent-no-result path. Without this log, a cancelled
// fetch leaves cache empty + lastError nil and the user sees // fetch leaves cache empty + lastError nil and the user sees
// perpetual loading with nothing in the diagnostics. // perpetual loading with nothing in the diagnostics.
NSLog("CodeBurn: fetch for \(key.period.rawValue)/\(key.provider.rawValue) cancelled before result was applied") NSLog("CodeBurn: fetch for \(key.label)/\(key.provider.rawValue) cancelled before result was applied")
return return
} }
// Day-rollover race guard: if the calendar date changed during the // Day-rollover race guard: if the calendar date changed during the
@ -375,7 +453,7 @@ final class AppStore {
// tick will refetch with today's data. // tick will refetch with today's data.
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
invalidateStaleDayCache() invalidateStaleDayCache()
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch") NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — calendar rolled mid-fetch")
return return
} }
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
@ -383,10 +461,10 @@ final class AppStore {
lastErrorByKey[key] = nil lastErrorByKey[key] = nil
} catch { } catch {
if Task.isCancelled { return } if Task.isCancelled { return }
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)") NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)")
if includeOptimize, cache[key] == nil { if includeOptimize, cache[key] == nil {
do { do {
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false) let fallback = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: false)
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
if generationAtStart != payloadRefreshGeneration { return } if generationAtStart != payloadRefreshGeneration { return }
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
@ -405,34 +483,34 @@ final class AppStore {
lastErrorByKey[key] = String(describing: error) lastErrorByKey[key] = String(describing: error)
} }
let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all) let allKey = PayloadCacheKey(period: key.period, provider: .all, day: key.day)
if key != allKey, cache[allKey]?.isFresh != true { if key != allKey, cache[allKey]?.isFresh != true {
await refreshQuietly(period: selectedPeriod) await refreshQuietly(period: key.period, day: key.day)
} }
} }
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge). /// 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. /// Does not toggle isLoading, so the popover's loading overlay is unaffected.
/// Always uses the .all provider since the menubar badge shows total spend. /// Always uses the .all provider since the menubar badge shows total spend.
func refreshQuietly(period: Period, force: Bool = false) async { func refreshQuietly(period: Period, day: String? = nil, force: Bool = false) async {
invalidateStaleDayCache() invalidateStaleDayCache()
let key = PayloadCacheKey(period: period, provider: .all) let key = PayloadCacheKey(period: period, provider: .all, day: day)
if !force, cache[key]?.isFresh == true { return } if !force, cache[key]?.isFresh == true { return }
if inFlightKeys.contains(key) { return } if inFlightKeys.contains(key) { return }
inFlightKeys.insert(key) inFlightKeys.insert(key)
attemptedKeys.insert(key) attemptedKeys.insert(key)
let cacheDateAtStart = cacheDate let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration let generationAtStart = payloadRefreshGeneration
if period == .today, let age = todayPayloadAgeSeconds, age > 120 { if day == nil && period == .today, let age = todayPayloadAgeSeconds, age > 120 {
NSLog("CodeBurn: refreshing stale today status payload after %ds", age) NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
} }
defer { defer {
inFlightKeys.remove(key) inFlightKeys.remove(key)
} }
do { do {
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false) let fresh = try await DataClient.fetch(period: period, day: day, provider: .all, includeOptimize: false)
if generationAtStart != payloadRefreshGeneration { if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch") NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch")
return return
} }
// Same day-rollover guard as refresh(): drop yesterday's payload if // Same day-rollover guard as refresh(): drop yesterday's payload if
@ -445,7 +523,7 @@ final class AppStore {
lastSuccessByKey[key] = Date() lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil lastErrorByKey[key] = nil
} catch { } catch {
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)") NSLog("CodeBurn: quiet refresh failed for \(key.label): \(error)")
} }
} }
@ -511,12 +589,10 @@ final class AppStore {
await captureSnapshots(for: usage) await captureSnapshots(for: usage)
return true return true
} catch let err as ClaudeSubscriptionService.FetchError { } catch let err as ClaudeSubscriptionService.FetchError {
if Task.isCancelled { return false }
guard gen == claudeRefreshGen else { return false } guard gen == claudeRefreshGen else { return false }
applyFetchError(err) applyFetchError(err)
return false return false
} catch { } catch {
if Task.isCancelled { return false }
guard gen == claudeRefreshGen else { return false } guard gen == claudeRefreshGen else { return false }
subscriptionError = sanitizeForUI(String(describing: error)) subscriptionError = sanitizeForUI(String(describing: error))
subscriptionLoadState = .failed subscriptionLoadState = .failed
@ -584,12 +660,10 @@ final class AppStore {
codexLoadState = .loaded codexLoadState = .loaded
return true return true
} catch let err as CodexSubscriptionService.FetchError { } catch let err as CodexSubscriptionService.FetchError {
if Task.isCancelled { return false }
guard gen == codexRefreshGen else { return false } guard gen == codexRefreshGen else { return false }
applyCodexFetchError(err) applyCodexFetchError(err)
return false return false
} catch { } catch {
if Task.isCancelled { return false }
guard gen == codexRefreshGen else { return false } guard gen == codexRefreshGen else { return false }
codexError = sanitizeForUI(String(describing: error)) codexError = sanitizeForUI(String(describing: error))
codexLoadState = .failed codexLoadState = .failed
@ -1017,8 +1091,6 @@ enum Period: String, CaseIterable, Identifiable {
} }
} }
/// Status item metrics intentionally stay to the coarse Settings choices.
/// The popover still offers 30 Days, but it is not a persisted status metric.
static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all] static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all]
var menubarMetricLabel: String { var menubarMetricLabel: String {

View file

@ -47,12 +47,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var manualRefreshTask: Task<Void, Never>? private var manualRefreshTask: Task<Void, Never>?
private var manualRefreshGeneration: UInt64 = 0 private var manualRefreshGeneration: UInt64 = 0
private var claudeQuotaRefreshTask: Task<Bool, Never>? private var claudeQuotaRefreshTask: Task<Bool, Never>?
private var claudeQuotaRefreshGeneration: UInt64 = 0
private var codexQuotaRefreshTask: Task<Bool, Never>? private var codexQuotaRefreshTask: Task<Bool, Never>?
private var codexQuotaRefreshGeneration: UInt64 = 0
private var refreshLoopHeartbeatAt: Date = .distantPast private var refreshLoopHeartbeatAt: Date = .distantPast
private var lastLaunchAgentHeartbeatAt: Date = .distantPast private var lastLaunchAgentHeartbeatAt: Date = .distantPast
private var forceRefreshBackoff = RefreshBackoff()
func applicationWillFinishLaunching(_ notification: Notification) { func applicationWillFinishLaunching(_ notification: Notification) {
// Set accessory policy before the app's focus chain forms. On macOS Tahoe // Set accessory policy before the app's focus chain forms. On macOS Tahoe
@ -171,7 +168,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
manualRefreshTask?.cancel() manualRefreshTask?.cancel()
manualRefreshTask = nil manualRefreshTask = nil
manualRefreshGeneration &+= 1 manualRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil statusPayloadRefreshStartedAt = nil
@ -191,7 +187,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
manualRefreshTask?.cancel() manualRefreshTask?.cancel()
manualRefreshTask = nil manualRefreshTask = nil
manualRefreshGeneration &+= 1 manualRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil statusPayloadRefreshStartedAt = nil
@ -298,29 +293,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var lastRefreshTime: Date = .distantPast private var lastRefreshTime: Date = .distantPast
private func cancelLiveQuotaRefreshTasks() {
claudeQuotaRefreshTask?.cancel()
claudeQuotaRefreshTask = nil
claudeQuotaRefreshGeneration &+= 1
codexQuotaRefreshTask?.cancel()
codexQuotaRefreshTask = nil
codexQuotaRefreshGeneration &+= 1
}
private func isForceRefreshPaused(now: Date = Date()) -> Bool {
if forceRefreshBackoff.isPaused(now: now) {
if let until = forceRefreshBackoff.pausedUntil {
store.pauseAutomaticRefresh(
until: until,
consecutiveStalls: forceRefreshBackoff.consecutiveStalls
)
}
return true
}
store.clearRefreshPause()
return false
}
@discardableResult @discardableResult
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool { private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
if forceRefreshTask != nil { if forceRefreshTask != nil {
@ -328,9 +300,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
NSLog("CodeBurn: force refresh task had no start timestamp - clearing") NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
forceRefreshTask?.cancel() forceRefreshTask?.cancel()
forceRefreshTask = nil forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1 forceRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
store.resetLoadingState() store.resetLoadingState()
return true return true
} }
@ -341,16 +311,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
forceRefreshTask = nil forceRefreshTask = nil
forceRefreshStartedAt = nil forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1 forceRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
if let pausedUntil = forceRefreshBackoff.recordStall(now: now) {
store.pauseAutomaticRefresh(
until: pausedUntil,
consecutiveStalls: forceRefreshBackoff.consecutiveStalls
)
NSLog("CodeBurn: force refresh paused after %d consecutive stalls until %@",
forceRefreshBackoff.consecutiveStalls,
pausedUntil as NSDate)
}
store.resetLoadingState() store.resetLoadingState()
return true return true
} }
@ -361,7 +321,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool { private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
if statusPayloadRefreshTask != nil { if statusPayloadRefreshTask != nil {
guard let started = statusPayloadRefreshStartedAt else { guard let started = statusPayloadRefreshStartedAt else {
NSLog("CodeBurn: status refresh task had no start timestamp - clearing") NSLog("CodeBurn: today status refresh task had no start timestamp - clearing")
statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil statusPayloadRefreshTask = nil
statusPayloadRefreshGeneration &+= 1 statusPayloadRefreshGeneration &+= 1
@ -369,7 +329,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
} }
let elapsed = now.timeIntervalSince(started) let elapsed = now.timeIntervalSince(started)
guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false } guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false }
NSLog("CodeBurn: status refresh stuck for %ds - cancelling", Int(elapsed)) NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed))
statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil statusPayloadRefreshStartedAt = nil
@ -406,7 +366,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) { private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
let now = Date() let now = Date()
_ = clearStaleForceRefreshIfNeeded(now: now) _ = clearStaleForceRefreshIfNeeded(now: now)
guard !isForceRefreshPaused(now: now) else { return }
if forceRefreshTask != nil { if forceRefreshTask != nil {
refreshStatusPayloadIfNeeded(reason: "blocked force refresh") refreshStatusPayloadIfNeeded(reason: "blocked force refresh")
} }
@ -427,8 +386,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
_ = await quotas _ = await quotas
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self, self.forceRefreshGeneration == generation else { return } guard let self, self.forceRefreshGeneration == generation else { return }
self.forceRefreshBackoff.recordSuccess()
self.store.clearRefreshPause()
self.forceRefreshTask = nil self.forceRefreshTask = nil
self.forceRefreshStartedAt = nil self.forceRefreshStartedAt = nil
self.lastRefreshTime = Date() self.lastRefreshTime = Date()
@ -514,11 +471,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let task = Task { [store] in let task = Task { [store] in
await store.refreshSubscriptionReportingSuccess() await store.refreshSubscriptionReportingSuccess()
} }
claudeQuotaRefreshGeneration &+= 1
let generation = claudeQuotaRefreshGeneration
claudeQuotaRefreshTask = task claudeQuotaRefreshTask = task
let result = await task.value let result = await task.value
if claudeQuotaRefreshGeneration == generation { if claudeQuotaRefreshTask != nil {
claudeQuotaRefreshTask = nil claudeQuotaRefreshTask = nil
} }
return result return result
@ -531,11 +486,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let task = Task { [store] in let task = Task { [store] in
await store.refreshCodexReportingSuccess() await store.refreshCodexReportingSuccess()
} }
codexQuotaRefreshGeneration &+= 1
let generation = codexQuotaRefreshGeneration
codexQuotaRefreshTask = task codexQuotaRefreshTask = task
let result = await task.value let result = await task.value
if codexQuotaRefreshGeneration == generation { if codexQuotaRefreshTask != nil {
codexQuotaRefreshTask = nil codexQuotaRefreshTask = nil
} }
return result return result
@ -573,12 +526,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
} }
private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) { private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
let now = Date() refreshLoopHeartbeatAt = Date()
refreshLoopHeartbeatAt = now
let hadForceRefreshInFlight = forceRefreshTask != nil let hadForceRefreshInFlight = forceRefreshTask != nil
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded(now: now) let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded(now: now) let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
guard !isForceRefreshPaused(now: now) else { return }
let clearedStaleLoading = store.clearStaleLoadingIfNeeded() let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
let statusPayloadStale = store.needsStatusPayloadRefresh let statusPayloadStale = store.needsStatusPayloadRefresh
let sinceLast = Date().timeIntervalSince(lastRefreshTime) let sinceLast = Date().timeIntervalSince(lastRefreshTime)
@ -632,9 +583,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
statusPayloadRefreshGeneration &+= 1 statusPayloadRefreshGeneration &+= 1
pendingRefreshWork?.cancel() pendingRefreshWork?.cancel()
pendingRefreshWork = nil pendingRefreshWork = nil
cancelLiveQuotaRefreshTasks()
forceRefreshBackoff.retryNow(resetStallCount: false)
store.clearRefreshPause()
stopRefreshTimer() stopRefreshTimer()
store.resetRefreshState(clearCache: true) store.resetRefreshState(clearCache: true)
lastRefreshTime = .distantPast lastRefreshTime = .distantPast
@ -653,8 +601,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.refreshStatusButton() self.refreshStatusButton()
_ = await quotas _ = await quotas
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
self.forceRefreshBackoff.recordSuccess()
self.store.clearRefreshPause()
self.manualRefreshTask = nil self.manualRefreshTask = nil
if self.refreshTimer == nil { if self.refreshTimer == nil {
self.startRefreshLoop() self.startRefreshLoop()

View file

@ -19,13 +19,21 @@ enum DataClientError: Error {
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route /// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore. /// commands through `/bin/zsh -c` anymore.
struct DataClient { struct DataClient {
static func fetch(period: Period, provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload { static func fetch(period: Period, day: String? = nil, days: Set<String> = [], provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
var subcommand = [ var subcommand = [
"status", "status",
"--format", "menubar-json", "--format", "menubar-json",
"--period", period.cliArg,
"--provider", provider.cliArg, "--provider", provider.cliArg,
] ]
if days.count > 1 {
subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
} else if let day {
subcommand.append(contentsOf: ["--day", day])
} else if let d = days.first {
subcommand.append(contentsOf: ["--day", d])
} else {
subcommand.append(contentsOf: ["--period", period.cliArg])
}
if !includeOptimize { if !includeOptimize {
subcommand.append("--no-optimize") subcommand.append("--no-optimize")
} }

View file

@ -79,7 +79,7 @@ struct HeatmapSection: View {
} else { } else {
PlanInsight(usage: store.subscription) PlanInsight(usage: store.subscription)
} }
case .trend: TrendInsight(days: store.payload.history.daily, period: store.selectedPeriod) case .trend: TrendInsight(days: store.payload.history.daily, period: store.trendPeriod)
case .forecast: ForecastInsight(days: store.payload.history.daily) case .forecast: ForecastInsight(days: store.payload.history.daily)
case .pulse: PulseInsight(payload: store.payload) case .pulse: PulseInsight(payload: store.payload)
case .stats: StatsInsight(payload: store.payload) case .stats: StatsInsight(payload: store.payload)
@ -1839,4 +1839,3 @@ private func relativeReset(_ date: Date) -> String {
let days = Int(ceil(hours / 24)) let days = Int(ceil(hours / 24))
return "in \(days)d" return "in \(days)d"
} }

View file

@ -53,7 +53,8 @@ struct HeroSection: View {
} }
} }
if store.selectedPeriod == .today, if !store.isDayMode,
store.selectedPeriod == .today,
store.dailyBudget > 0, store.dailyBudget > 0,
let todayCost = store.todayPayload?.current.cost, let todayCost = store.todayPayload?.current.cost,
todayCost >= store.dailyBudget { todayCost >= store.dailyBudget {
@ -92,7 +93,7 @@ struct HeroSection: View {
private var caption: String { private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if store.selectedPeriod == .today { if !store.isDayMode && store.selectedPeriod == .today {
return "\(label) · \(todayDate)" return "\(label) · \(todayDate)"
} }
return label return label

View file

@ -11,14 +11,6 @@ struct MenuBarContent: View {
Divider() Divider()
if let message = store.refreshPauseMessage {
RefreshPausedBanner(
message: message,
retry: { refreshNow() }
)
Divider()
}
if showAgentTabs { if showAgentTabs {
AgentTabStrip() AgentTabStrip()
Divider() Divider()
@ -32,7 +24,7 @@ struct MenuBarContent: View {
PeriodSegmentedControl() PeriodSegmentedControl()
Divider().opacity(0.5) Divider().opacity(0.5)
if isFilteredEmpty { if isFilteredEmpty {
EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod) EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel)
} else { } else {
HeatmapSection() HeatmapSection()
.padding(.horizontal, 14) .padding(.horizontal, 14)
@ -57,22 +49,15 @@ struct MenuBarContent: View {
// error, etc.), surface a retry card instead of leaving the // error, etc.), surface a retry card instead of leaving the
// user stuck on a perpetual "Loading..." spinner. // user stuck on a perpetual "Loading..." spinner.
if !store.hasCachedData { if !store.hasCachedData {
if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad { if let err = store.lastError {
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
.transition(.opacity)
} else if let err = store.lastError {
FetchErrorOverlay( FetchErrorOverlay(
error: err, error: err,
periodLabel: store.selectedPeriod.rawValue, periodLabel: store.selectionLabel,
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } } retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
) )
.transition(.opacity) .transition(.opacity)
} else { } else {
FetchErrorOverlay( BurnLoadingOverlay(periodLabel: store.selectionLabel)
error: "The last refresh stopped before returning data. CodeBurn will keep retrying, or you can retry now.",
periodLabel: store.selectedPeriod.rawValue,
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
)
.transition(.opacity) .transition(.opacity)
} }
} }
@ -120,42 +105,16 @@ struct MenuBarContent: View {
} }
private struct RefreshPausedBanner: View {
let message: String
let retry: () -> Void
var body: some View {
HStack(spacing: 10) {
Image(systemName: "pause.circle.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text(message)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 6)
Button("Retry", action: retry)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.08))
}
}
private struct EmptyProviderState: View { private struct EmptyProviderState: View {
let provider: ProviderFilter let provider: ProviderFilter
let period: Period let periodLabel: String
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "tray") Image(systemName: "tray")
.font(.system(size: 26)) .font(.system(size: 26))
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
Text("No \(provider.rawValue) data for \(periodPhrase)") Text("No \(provider.rawValue) data for \(periodLabel)")
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -164,15 +123,6 @@ private struct EmptyProviderState: View {
.padding(.vertical, 60) .padding(.vertical, 60)
} }
private var periodPhrase: String {
switch period {
case .today: "today"
case .sevenDays: "the last 7 days"
case .thirtyDays: "the last 30 days"
case .month: "this month"
case .all: "the last 6 months"
}
}
} }
/// Shown when a fetch failed and the cache is still empty for this key. The /// Shown when a fetch failed and the cache is still empty for this key. The
@ -628,7 +578,11 @@ struct FooterBar: View {
} }
private func refreshNow() { private func refreshNow() {
MenuBarContent.refreshNow(store: store) if let delegate = NSApp.delegate as? AppDelegate {
delegate.refreshSubscriptionNow()
} else {
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
}
} }
private enum ExportFormat { private enum ExportFormat {
@ -694,17 +648,3 @@ struct FooterBar: View {
CLICurrencyConfig.persist(code: code) CLICurrencyConfig.persist(code: code)
} }
} }
private extension MenuBarContent {
static func refreshNow(store: AppStore) {
if let delegate = NSApp.delegate as? AppDelegate {
delegate.refreshSubscriptionNow()
} else {
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
}
}
func refreshNow() {
Self.refreshNow(store: store)
}
}

View file

@ -2,16 +2,18 @@ import SwiftUI
struct PeriodSegmentedControl: View { struct PeriodSegmentedControl: View {
@Environment(AppStore.self) private var store @Environment(AppStore.self) private var store
@State private var showingCalendar = false
var body: some View { var body: some View {
HStack(spacing: 1) { HStack(spacing: 1) {
ForEach(Period.allCases) { period in ForEach(Period.allCases) { period in
let isActive = !store.isDayMode && store.selectedPeriod == period
Button { Button {
store.switchTo(period: period) store.switchTo(period: period)
} label: { } label: {
Text(period.rawValue) Text(period.rawValue)
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(store.selectedPeriod == period ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 4) .padding(.vertical, 4)
.contentShape(Rectangle()) .contentShape(Rectangle())
@ -19,10 +21,31 @@ struct PeriodSegmentedControl: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.background( .background(
RoundedRectangle(cornerRadius: 5) RoundedRectangle(cornerRadius: 5)
.fill(store.selectedPeriod == period ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear) .fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(store.selectedPeriod == period ? 0.06 : 0), radius: 1, y: 0.5) .shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5)
) )
} }
Button {
showingCalendar.toggle()
} label: {
Image(systemName: "calendar")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(store.isDayMode ? Theme.brandAccent : .secondary)
.frame(width: 28)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.isDayMode ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(store.isDayMode ? 0.06 : 0), radius: 1, y: 0.5)
)
.popover(isPresented: $showingCalendar, arrowEdge: .bottom) {
CalendarPopover(isPresented: $showingCalendar)
.environment(store)
}
} }
.padding(2) .padding(2)
.background( .background(
@ -34,3 +57,248 @@ struct PeriodSegmentedControl: View {
.padding(.bottom, 10) .padding(.bottom, 10)
} }
} }
private struct CalendarPopover: View {
@Environment(AppStore.self) private var store
@Binding var isPresented: Bool
@State private var displayMonth = Date()
@State private var pending: Set<String> = []
private let calendar = Calendar.current
private let weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
private let cellSize: CGFloat = 30
var body: some View {
VStack(spacing: 0) {
HStack {
Button { shiftMonth(-1) } label: {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
Text(monthYearLabel)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
Button { shiftMonth(1) } label: {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(canGoForward ? .secondary : Color.secondary.opacity(0.3))
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!canGoForward)
}
.padding(.horizontal, 10)
.padding(.top, 10)
.padding(.bottom, 6)
HStack(spacing: 0) {
ForEach(weekdays, id: \.self) { day in
Text(day)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
.frame(width: cellSize, height: 16)
}
}
.padding(.bottom, 2)
LazyVGrid(columns: Array(repeating: GridItem(.fixed(cellSize), spacing: 0), count: 7), spacing: 2) {
ForEach(dayCells, id: \.id) { cell in
DayCellView(
cell: cell,
isSelected: pending.contains(cell.dateString),
isToday: cell.dateString == todayString,
isFuture: cell.dateString > todayString
) {
toggleDay(cell.dateString)
}
}
}
.padding(.horizontal, 6)
HStack(spacing: 8) {
if !pending.isEmpty {
Button("Clear") {
pending = []
}
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.buttonStyle(.plain)
}
Spacer()
Text(selectionSummary)
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.lineLimit(1)
Spacer()
Button {
if !pending.isEmpty {
store.switchTo(days: pending)
} else {
store.switchTo(period: store.selectedPeriod)
}
isPresented = false
} label: {
Text("Done")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(pending.isEmpty ? Color.secondary.opacity(0.3) : Theme.brandAccent)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 10)
}
.frame(width: cellSize * 7 + 24)
.onAppear {
pending = store.selectedDays
if let first = store.selectedDays.sorted().first,
let date = AppStore.dayFormatter.date(from: first) {
displayMonth = date
}
}
}
private var todayString: String {
AppStore.dayString(from: Date())
}
private var monthYearLabel: String {
let f = DateFormatter()
f.dateFormat = "MMMM yyyy"
return f.string(from: displayMonth)
}
private var canGoForward: Bool {
let nextMonth = calendar.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth
return calendar.startOfDay(for: nextMonth) <= calendar.startOfDay(for: Date())
}
private var selectionSummary: String {
if pending.isEmpty { return "Pick dates" }
if pending.count == 1 { return "1 day" }
return "\(pending.count) days"
}
private func shiftMonth(_ delta: Int) {
if let next = calendar.date(byAdding: .month, value: delta, to: displayMonth) {
displayMonth = next
}
}
private func toggleDay(_ day: String) {
guard day <= todayString else { return }
if pending.contains(day) {
pending.remove(day)
} else {
pending.insert(day)
}
}
var dayCells: [DayCell] {
let comps = calendar.dateComponents([.year, .month], from: displayMonth)
guard let firstOfMonth = calendar.date(from: comps),
let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { return [] }
var weekdayOfFirst = calendar.component(.weekday, from: firstOfMonth) - 2
if weekdayOfFirst < 0 { weekdayOfFirst += 7 }
var cells: [DayCell] = []
for offset in stride(from: -weekdayOfFirst, to: 0, by: 1) {
if let date = calendar.date(byAdding: .day, value: offset, to: firstOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "prev-\(offset)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
for day in range {
if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) {
cells.append(DayCell(id: "cur-\(day)", day: day, dateString: AppStore.dayString(from: date), isCurrentMonth: true))
}
}
let remainder = (7 - cells.count % 7) % 7
if let lastOfMonth = calendar.date(byAdding: .day, value: range.count - 1, to: firstOfMonth) {
for i in 1...max(remainder, 1) {
if let date = calendar.date(byAdding: .day, value: i, to: lastOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "next-\(i)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
}
if cells.count % 7 != 0 {
cells = Array(cells.prefix(cells.count - cells.count % 7))
}
return cells
}
}
private struct DayCell: Identifiable {
let id: String
let day: Int
let dateString: String
let isCurrentMonth: Bool
}
private struct DayCellView: View {
let cell: DayCell
let isSelected: Bool
let isToday: Bool
let isFuture: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text("\(cell.day)")
.font(.system(size: 11, weight: isToday ? .bold : .regular))
.foregroundStyle(foregroundColor)
.frame(width: 28, height: 28)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(backgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isToday && !isSelected ? Theme.brandAccent.opacity(0.5) : .clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(isFuture)
}
private var foregroundColor: Color {
if isFuture { return Color.secondary.opacity(0.25) }
if isSelected { return .white }
if !cell.isCurrentMonth { return Color.secondary.opacity(0.4) }
if isToday { return Theme.brandAccent }
return .primary
}
private var backgroundColor: Color {
if isSelected { return Theme.brandAccent }
return .clear
}
}

View file

@ -58,6 +58,49 @@ function parseLocalDate(s: string): Date {
return date return date
} }
function endOfLocalDay(date: Date): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
END_OF_DAY_HOURS,
END_OF_DAY_MINUTES,
END_OF_DAY_SECONDS,
END_OF_DAY_MS,
)
}
export function dayRangeForDate(date: Date): DateRange {
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate())
return { start, end: endOfLocalDay(start) }
}
export function formatDayRangeLabel(day: string): string {
return `Day (${day})`
}
export function shiftDay(day: string, delta: number): string {
const date = parseLocalDate(day)
return toDateString(new Date(date.getFullYear(), date.getMonth(), date.getDate() + delta))
}
export function parseDayFlag(day: string | undefined): { day: string; range: DateRange; label: string } | null {
if (day === undefined) return null
const now = new Date()
let date: Date
if (day === 'today') {
date = new Date(now.getFullYear(), now.getMonth(), now.getDate())
} else if (day === 'yesterday') {
date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
} else {
date = parseLocalDate(day)
}
const resolvedDay = toDateString(date)
return { day: resolvedDay, range: dayRangeForDate(date), label: formatDayRangeLabel(resolvedDay) }
}
export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null { export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
if (from === undefined && to === undefined) return null if (from === undefined && to === undefined) return null
@ -72,15 +115,7 @@ export function parseDateRangeFlags(from: string | undefined, to: string | undef
: new Date(now.getTime() - ALL_TIME_FALLBACK_MS) : new Date(now.getTime() - ALL_TIME_FALLBACK_MS)
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate()) const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
const end = new Date( const end = endOfLocalDay(endDate)
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate(),
END_OF_DAY_HOURS,
END_OF_DAY_MINUTES,
END_OF_DAY_SECONDS,
END_OF_DAY_MS,
)
if (start > end) { if (start > end) {
throw new Error(`--from must not be after --to (got ${from} > ${to})`) throw new Error(`--from must not be after --to (got ${from} > ${to})`)
@ -113,12 +148,11 @@ export function getDateRange(period: string): { range: DateRange; label: string
switch (period) { switch (period) {
case 'today': { case 'today': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
return { range: { start, end }, label: `Today (${toDateString(start)})` } return { range: dayRangeForDate(start), label: `Today (${toDateString(start)})` }
} }
case 'yesterday': { case 'yesterday': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, END_OF_DAY_HOURS, END_OF_DAY_MINUTES, END_OF_DAY_SECONDS, END_OF_DAY_MS) return { range: dayRangeForDate(start), label: `Yesterday (${toDateString(start)})` }
return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` }
} }
case 'week': { case 'week': {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7) const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
@ -145,6 +179,22 @@ export function getDateRange(period: string): { range: DateRange; label: string
} }
} }
export function parseDaysFlag(days: string | undefined): { days: Set<string>; range: DateRange; label: string } | null {
if (days === undefined) return null
const list = days.split(',').map(s => s.trim()).filter(Boolean)
if (list.length === 0) return null
const dates = list.map(parseLocalDate)
const strings = dates.map(toDateString)
const sorted = [...strings].sort()
const startDate = parseLocalDate(sorted[0]!)
const endDate = parseLocalDate(sorted[sorted.length - 1]!)
return {
days: new Set(sorted),
range: { start: startDate, end: endOfLocalDay(endDate) },
label: sorted.length === 1 ? sorted[0]! : `${sorted.length} days (${sorted[0]} .. ${sorted[sorted.length - 1]})`,
}
}
export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string { export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string {
return `${from ?? 'all'} to ${to ?? 'today'}` return `${from ?? 'all'} to ${to ?? 'today'}`
} }

View file

@ -14,7 +14,7 @@ import { dateKey } from './day-aggregator.js'
import { CompareView } from './compare.js' import { CompareView } from './compare.js'
import { getPlanUsages, type PlanUsage } from './plan-usage.js' import { getPlanUsages, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js' import { planDisplayName } from './plans.js'
import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js' import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js'
import { patchStdoutForWindows } from './ink-win.js' import { patchStdoutForWindows } from './ink-win.js'
type View = 'dashboard' | 'optimize' | 'compare' type View = 'dashboard' | 'optimize' | 'compare'
@ -102,6 +102,10 @@ function getPeriodRange(period: Period): { start: Date; end: Date } {
return getDateRange(period).range return getDateRange(period).range
} }
function getDayRange(day: string): DateRange {
return parseDayFlag(day)!.range
}
function isHeavyPeriod(period: Period): boolean { function isHeavyPeriod(period: Period): boolean {
return HEAVY_PERIODS.has(period) return HEAVY_PERIODS.has(period)
} }
@ -648,14 +652,16 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore,
) )
} }
function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable, customRange }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean; customRange?: boolean }) { function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable, customRange, dayMode }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean; customRange?: boolean; dayMode?: boolean }) {
const isOptimize = view === 'optimize' const isOptimize = view === 'optimize'
return ( return (
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}> <Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
<Text> <Text>
{isOptimize {isOptimize
? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text><Text color={ORANGE} bold>j</Text><Text dimColor>/</Text><Text color={ORANGE} bold>k</Text><Text dimColor> scroll </Text></> ? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text><Text color={ORANGE} bold>j</Text><Text dimColor>/</Text><Text color={ORANGE} bold>k</Text><Text dimColor> scroll </Text></>
: !customRange : dayMode
? <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> day </Text></>
: !customRange
? <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></> ? <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></>
: null} : null}
<Text color={ORANGE} bold>q</Text><Text dimColor> quit</Text> <Text color={ORANGE} bold>q</Text><Text dimColor> quit</Text>
@ -668,6 +674,11 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable,
<Text color={ORANGE} bold>5</Text><Text dimColor> 6 months</Text> <Text color={ORANGE} bold>5</Text><Text dimColor> 6 months</Text>
</> </>
)} )}
{!customRange && !isOptimize && (
<>
<Text dimColor> </Text><Text color={ORANGE} bold>d</Text><Text dimColor>{dayMode ? ' exit day' : ' yesterday'}</Text>
</>
)}
{!isOptimize && optimizeAvailable && ( {!isOptimize && optimizeAvailable && (
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text>{findingCount != null && findingCount > 0 ? <Text color="#F55B5B"> ({findingCount})</Text> : null}</> <><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text>{findingCount != null && findingCount > 0 ? <Text color="#F55B5B"> ({findingCount})</Text> : null}</>
)} )}
@ -685,15 +696,16 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
return <>{children}</> return <>{children}</>
} }
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[] }) { function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) {
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns) const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
const isCursor = activeProvider === 'cursor' const isCursor = activeProvider === 'cursor'
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel> const activeLabel = label ?? PERIOD_LABELS[period]
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {activeLabel}.</Text></Panel>
const pw = wide ? halfWidth : dashWidth const pw = wide ? halfWidth : dashWidth
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14) const days = dayMode ? 1 : period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
return ( return (
<Box flexDirection="column" width={dashWidth}> <Box flexDirection="column" width={dashWidth}>
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsages={planUsages} /> <Overview projects={projects} label={activeLabel} width={dashWidth} planUsages={planUsages} />
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row> <Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row> <Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{isCursor ? ( {isCursor ? (
@ -705,7 +717,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
) )
} }
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: { function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: {
initialProjects: ProjectSummary[] initialProjects: ProjectSummary[]
initialPeriod: Period initialPeriod: Period
initialProvider: string initialProvider: string
@ -715,6 +727,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
excludeFilter?: string[] excludeFilter?: string[]
customRange?: DateRange | null customRange?: DateRange | null
customRangeLabel?: string customRangeLabel?: string
initialDay?: string
}) { }) {
const { exit } = useApp() const { exit } = useApp()
const [period, setPeriod] = useState<Period>(initialPeriod) const [period, setPeriod] = useState<Period>(initialPeriod)
@ -727,11 +740,13 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const [optimizeLoading, setOptimizeLoading] = useState(false) const [optimizeLoading, setOptimizeLoading] = useState(false)
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map()) const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
const [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? []) const [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? [])
const [dayDate, setDayDate] = useState<string | null>(initialDay ?? null)
// Cursor for the OptimizeView's findings window. Reset whenever the user // Cursor for the OptimizeView's findings window. Reset whenever the user
// leaves the optimize view OR the underlying findings change so a long // leaves the optimize view OR the underlying findings change so a long
// findings list never strands the user past the new array length. // findings list never strands the user past the new array length.
const [findingsCursor, setFindingsCursor] = useState(0) const [findingsCursor, setFindingsCursor] = useState(0)
const isCustomRange = customRange != null const isDayMode = dayDate != null
const isCustomRange = customRange != null && !isDayMode
const { columns } = useWindowSize() const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns) const { dashWidth } = getLayout(columns)
const multipleProviders = detectedProviders.length > 1 const multipleProviders = detectedProviders.length > 1
@ -743,8 +758,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reloadGenerationRef = useRef(0) const reloadGenerationRef = useRef(0)
const reloadInFlightRef = useRef(false) const reloadInFlightRef = useRef(false)
const currentReloadRef = useRef<{ period: Period; provider: string } | null>(null) const currentReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
const pendingReloadRef = useRef<{ period: Period; provider: string } | null>(null) const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
const findingCount = optimizeResult?.findings.length ?? 0 const findingCount = optimizeResult?.findings.length ?? 0
useEffect(() => { useEffect(() => {
@ -773,31 +788,31 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
return () => { cancelled = true } return () => { cancelled = true }
}, [projects]) }, [projects])
const reloadData = useCallback(async (p: Period, prov: string) => { const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null) => {
if (reloadInFlightRef.current) { if (reloadInFlightRef.current) {
const current = currentReloadRef.current const current = currentReloadRef.current
if (current?.period === p && current.provider === prov) { if (current?.period === p && current.provider === prov && current.day === day) {
pendingReloadRef.current = null pendingReloadRef.current = null
return return
} }
reloadGenerationRef.current++ reloadGenerationRef.current++
pendingReloadRef.current = { period: p, provider: prov } pendingReloadRef.current = { period: p, provider: prov, day }
return return
} }
reloadInFlightRef.current = true reloadInFlightRef.current = true
currentReloadRef.current = { period: p, provider: prov } currentReloadRef.current = { period: p, provider: prov, day }
const generation = ++reloadGenerationRef.current const generation = ++reloadGenerationRef.current
setLoading(true) setLoading(true)
setOptimizeLoading(false) setOptimizeLoading(false)
setOptimizeResult(null) setOptimizeResult(null)
try { try {
if (isHeavyPeriod(p)) { if (!day && isHeavyPeriod(p)) {
setProjects([]) setProjects([])
setProjectBudgets(new Map()) setProjectBudgets(new Map())
await nextTick() await nextTick()
if (reloadGenerationRef.current !== generation) return if (reloadGenerationRef.current !== generation) return
} }
const range = getPeriodRange(p) const range = day ? getDayRange(day) : getPeriodRange(p)
const data = await parseAllSessions(range, prov) const data = await parseAllSessions(range, prov)
if (reloadGenerationRef.current !== generation) return if (reloadGenerationRef.current !== generation) return
@ -819,11 +834,15 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const pending = pendingReloadRef.current const pending = pendingReloadRef.current
pendingReloadRef.current = null pendingReloadRef.current = null
if (pending) { if (pending) {
void reloadData(pending.period, pending.provider) void reloadData(pending.period, pending.provider, pending.day)
} }
} }
}, [projectFilter, excludeFilter]) }, [projectFilter, excludeFilter])
const currentRange = useCallback(() => {
return dayDate ? getDayRange(dayDate) : getPeriodRange(period)
}, [dayDate, period])
const loadOptimizeResult = useCallback(async () => { const loadOptimizeResult = useCallback(async () => {
if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return
setView('optimize') setView('optimize')
@ -833,44 +852,63 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const generation = reloadGenerationRef.current const generation = reloadGenerationRef.current
setOptimizeLoading(true) setOptimizeLoading(true)
try { try {
const result = await scanAndDetect(projects, getPeriodRange(period)) const result = await scanAndDetect(projects, currentRange())
if (reloadGenerationRef.current === generation) setOptimizeResult(result) if (reloadGenerationRef.current === generation) setOptimizeResult(result)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
if (reloadGenerationRef.current === generation) setOptimizeLoading(false) if (reloadGenerationRef.current === generation) setOptimizeLoading(false)
} }
}, [optimizeAvailable, projects, period, optimizeLoading, optimizeResult]) }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult])
useEffect(() => { useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return if (!refreshSeconds || refreshSeconds <= 0) return
if (isHeavyPeriod(period)) return if (!dayDate && isHeavyPeriod(period)) return
const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000) const id = setInterval(() => { reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000)
return () => clearInterval(id) return () => clearInterval(id)
}, [refreshSeconds, period, activeProvider, reloadData]) }, [refreshSeconds, period, activeProvider, dayDate, reloadData])
const switchPeriod = useCallback((np: Period) => { const switchPeriod = useCallback((np: Period) => {
if (np === period) return if (np === period && !dayDate) return
// Clear projects + flip loading synchronously so the dashboard never // Clear projects + flip loading synchronously so the dashboard never
// renders the new period label over the old period's numbers between // renders the new period label over the old period's numbers between
// setPeriod() and the reloadData() promise resolving. Without this, // setPeriod() and the reloadData() promise resolving. Without this,
// there's a frame-to-hundreds-of-ms window where users saw wrong // there's a frame-to-hundreds-of-ms window where users saw wrong
// figures captioned with the new period. // figures captioned with the new period.
setPeriod(np) setPeriod(np)
setDayDate(null)
setProjects([]) setProjects([])
setLoading(true) setLoading(true)
if (debounceRef.current) clearTimeout(debounceRef.current) if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600) debounceRef.current = setTimeout(() => { reloadData(np, activeProvider, null) }, 600)
}, [period, activeProvider, reloadData]) }, [period, activeProvider, dayDate, reloadData])
const switchPeriodImmediate = useCallback(async (np: Period) => { const switchPeriodImmediate = useCallback(async (np: Period) => {
if (np === period) return if (np === period && !dayDate) return
setPeriod(np) setPeriod(np)
setDayDate(null)
setProjects([]) setProjects([])
setLoading(true) setLoading(true)
if (debounceRef.current) clearTimeout(debounceRef.current) if (debounceRef.current) clearTimeout(debounceRef.current)
await reloadData(np, activeProvider) await reloadData(np, activeProvider, null)
}, [period, activeProvider, reloadData]) }, [period, activeProvider, dayDate, reloadData])
const switchDay = useCallback(async (nextDay: string) => {
const today = parseDayFlag('today')!.day
const clampedDay = nextDay > today ? today : nextDay
if (clampedDay === dayDate) return
setDayDate(clampedDay)
setProjects([])
setLoading(true)
setView('dashboard')
if (debounceRef.current) clearTimeout(debounceRef.current)
await reloadData(period, activeProvider, clampedDay)
}, [period, activeProvider, dayDate, reloadData])
const enterYesterday = useCallback(async () => {
const yesterday = parseDayFlag('yesterday')!.day
await switchDay(yesterday)
}, [switchDay])
useInput((input, key) => { useInput((input, key) => {
if (input === 'q') { exit(); return } if (input === 'q') { exit(); return }
@ -881,6 +919,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE) const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE)
if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return } if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return }
if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return } if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return }
return
} }
if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return } if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return }
if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return } if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return }
@ -888,18 +927,40 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length] const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length]
setActiveProvider(next); setView('dashboard') setActiveProvider(next); setView('dashboard')
if (debounceRef.current) clearTimeout(debounceRef.current) if (debounceRef.current) clearTimeout(debounceRef.current)
reloadData(period, next); return reloadData(period, next, dayDate); return
} }
// Period switches reload the underlying data. Disable them while the // Period switches reload the underlying data. Disable them while the
// compare view is mounted; the compare view re-aggregates from // compare view is mounted; the compare view re-aggregates from
// `projects` and would visibly change underneath the user without any // `projects` and would visibly change underneath the user without any
// affordance back to the dashboard. Press `b` or Esc to return first. // affordance back to the dashboard. Press `b` or Esc to return first.
if (view === 'compare') return if (view === 'compare') return
if (!customRange && input === 'd') {
if (dayDate) {
setDayDate(null)
setProjects([])
setLoading(true)
void reloadData(period, activeProvider, null)
} else {
void enterYesterday()
}
return
}
// Also disable while a custom --from/--to range is in effect. Switching // Also disable while a custom --from/--to range is in effect. Switching
// period would silently abandon the user's explicit range and reload // period would silently abandon the user's explicit range and reload
// standard period data; the period tab strip is hidden in this mode so // standard period data; the period tab strip is hidden in this mode so
// users have no expectation that 1-5 should do anything. // users have no expectation that 1-5 should do anything.
if (isCustomRange) return if (isCustomRange) return
if (dayDate) {
if (key.leftArrow) { void switchDay(shiftDay(dayDate, -1)); return }
if (key.rightArrow || key.tab) { void switchDay(shiftDay(dayDate, 1)); return }
if (key.escape || input === 'b') {
setDayDate(null)
setProjects([])
setLoading(true)
void reloadData(period, activeProvider, null)
return
}
}
const idx = PERIODS.indexOf(period) const idx = PERIODS.indexOf(period)
if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]!) if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]!)
else if (key.rightArrow || key.tab) switchPeriod(PERIODS[(idx + 1) % PERIODS.length]!) else if (key.rightArrow || key.tab) switchPeriod(PERIODS[(idx + 1) % PERIODS.length]!)
@ -910,12 +971,13 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
else if (input === '5') switchPeriodImmediate('all') else if (input === '5') switchPeriodImmediate('all')
}) })
const headerLabel = customRangeLabel ?? PERIOD_LABELS[period] const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period]
if (loading || optimizeLoading) { if (loading || optimizeLoading) {
return ( return (
<Box flexDirection="column" width={dashWidth}> <Box flexDirection="column" width={dashWidth}>
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />} {!isCustomRange && !isDayMode && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />}
{isDayMode && <DayBanner label={headerLabel} width={dashWidth} />}
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />} {isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
{view === 'compare' {view === 'compare'
? <Box flexDirection="column" paddingX={2} paddingY={1}> ? <Box flexDirection="column" paddingX={2} paddingY={1}>
@ -928,21 +990,30 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
: view === 'optimize' : view === 'optimize'
? <Panel title="CodeBurn Optimize" color={ORANGE} width={dashWidth}><Text dimColor>Scanning {headerLabel}...</Text></Panel> ? <Panel title="CodeBurn Optimize" color={ORANGE} width={dashWidth}><Text dimColor>Scanning {headerLabel}...</Text></Panel>
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>} : <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} />} {view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} dayMode={isDayMode} />}
</Box> </Box>
) )
} }
return ( return (
<Box flexDirection="column" width={dashWidth}> <Box flexDirection="column" width={dashWidth}>
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders && view !== 'compare'} />} {!isCustomRange && !isDayMode && <PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders && view !== 'compare'} />}
{isDayMode && <DayBanner label={headerLabel} width={dashWidth} />}
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />} {isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
{view === 'compare' {view === 'compare'
? <CompareView projects={projects} onBack={() => setView('dashboard')} /> ? <CompareView projects={projects} onBack={() => setView('dashboard')} />
: view === 'optimize' && optimizeResult : view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} /> ? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} />} : <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} />} {view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
</Box>
)
}
function DayBanner({ label, width }: { label: string; width: number }) {
return (
<Box width={width} paddingX={1} marginBottom={1}>
<Text color={ORANGE} bold>{label}</Text>
</Box> </Box>
) )
} }
@ -956,31 +1027,33 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) {
) )
} }
function StaticDashboard({ projects, period, activeProvider, planUsages }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[] }) { function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) {
const { columns } = useWindowSize() const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns) const { dashWidth } = getLayout(columns)
return ( return (
<Box flexDirection="column" width={dashWidth}> <Box flexDirection="column" width={dashWidth}>
<PeriodTabs active={period} /> {dayMode ? <DayBanner label={label ?? PERIOD_LABELS[period]} width={dashWidth} /> : <PeriodTabs active={period} />}
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} /> <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} label={label} dayMode={dayMode} />
</Box> </Box>
) )
} }
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string): Promise<void> { export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise<void> {
await loadPricing() await loadPricing()
const range = customRange ?? getPeriodRange(period) const dayRange = initialDay ? getDayRange(initialDay) : null
const range = dayRange ?? customRange ?? getPeriodRange(period)
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter) const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const planUsages = await getPlanUsages() const planUsages = await getPlanUsages()
const isTTY = process.stdin.isTTY && process.stdout.isTTY const isTTY = process.stdin.isTTY && process.stdout.isTTY
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
patchStdoutForWindows() patchStdoutForWindows()
if (isTTY) { if (isTTY) {
const { waitUntilExit } = render( const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} /> <InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} />
) )
await waitUntilExit() await waitUntilExit()
} else { } else {
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} />, { patchConsole: false }) const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} />, { patchConsole: false })
unmount() unmount()
} }
} }

View file

@ -3,7 +3,7 @@ import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js' import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js' import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js' import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js' import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from './parser.js'
import { convertCost } from './currency.js' import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js' import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js' import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js'
@ -13,15 +13,10 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './d
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js' import { aggregateModelEfficiency } from './model-efficiency.js'
import { renderDashboard } from './dashboard.js' import { renderDashboard } from './dashboard.js'
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize, scanAndDetect } from './optimize.js' import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js' import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js' import { getAllProviders } from './providers/index.js'
import {
installAntigravityStatusLineHook,
runAgyStatusLineHook,
uninstallAntigravityStatusLineHook,
} from './antigravity-statusline.js'
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js' import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js' import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js' import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
@ -361,6 +356,7 @@ program
.command('report', { isDefault: true }) .command('report', { isDefault: true })
.description('Interactive usage dashboard') .description('Interactive usage dashboard')
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week') .option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
.option('--day <date>', 'Single day to review (YYYY-MM-DD, today, or yesterday). Overrides --period when set')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set') .option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set') .option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
@ -371,7 +367,12 @@ program
.action(async (opts) => { .action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'report') assertFormat(opts.format, ['tui', 'json'], 'report')
let customRange: DateRange | null = null let customRange: DateRange | null = null
let daySelection: ReturnType<typeof parseDayFlag> = null
try { try {
if (opts.day && (opts.from || opts.to)) {
throw new Error('--day cannot be combined with --from or --to')
}
daySelection = parseDayFlag(opts.day)
customRange = parseDateRangeFlags(opts.from, opts.to) customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@ -382,21 +383,23 @@ program
const period = toPeriod(opts.period) const period = toPeriod(opts.period)
if (opts.format === 'json') { if (opts.format === 'json') {
await loadPricing() await loadPricing()
if (customRange) { if (daySelection || customRange) {
const label = formatDateRangeLabel(opts.from, opts.to) const range = daySelection?.range ?? customRange!
const label = daySelection?.label ?? formatDateRangeLabel(opts.from, opts.to)
const periodKey = daySelection ? 'day' : 'custom'
const projects = filterProjectsByName( const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider), await parseAllSessions(range, opts.provider),
opts.project, opts.project,
opts.exclude, opts.exclude,
) )
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, 'custom')), null, 2)) console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, periodKey)), null, 2))
} else { } else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude) await runJsonReport(period, opts.provider, opts.project, opts.exclude)
} }
return return
} }
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel) await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day)
}) })
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@ -447,6 +450,10 @@ program
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, []) .option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, []) .option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today') .option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--day <date>', 'Single day for menubar-json (YYYY-MM-DD, today, or yesterday). Overrides --period when set')
.option('--from <date>', 'Start date (YYYY-MM-DD) for custom range')
.option('--to <date>', 'End date (YYYY-MM-DD) for custom range')
.option('--days <dates>', 'Comma-separated dates (YYYY-MM-DD) for multi-day selection')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => { .action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
@ -454,7 +461,14 @@ program
const pf = opts.provider const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') { if (opts.format === 'menubar-json') {
const periodInfo = getDateRange(opts.period) const daysSelection = parseDaysFlag(opts.days)
const customRange = daysSelection ? null : parseDateRangeFlags(opts.from, opts.to)
const daySelection = parseDayFlag(opts.day)
const periodInfo = daysSelection
? { range: daysSelection.range, label: daysSelection.label }
: customRange
? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) }
: daySelection ?? getDateRange(opts.period)
const now = new Date() const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const todayRange: DateRange = { start: todayStart, end: now } const todayRange: DateRange = { start: todayStart, end: now }
@ -462,6 +476,7 @@ program
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const rangeStartStr = toDateString(periodInfo.range.start) const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end) const rangeEndStr = toDateString(periodInfo.range.end)
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
const isAllProviders = pf === 'all' const isAllProviders = pf === 'all'
let todayAllProjects: ProjectSummary[] | null = null let todayAllProjects: ProjectSummary[] | null = null
@ -491,26 +506,34 @@ program
cache = await hydrateCache() cache = await hydrateCache()
const todayProjects = await getTodayAllProjects() const todayProjects = await getTodayAllProjects()
const todayDays = await getTodayAllDays() const todayDays = await getTodayAllDays()
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) const historicalDays = rangeStartStr <= historicalRangeEndStr
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
: []
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays
currentData = buildPeriodDataFromDays(allDays, periodInfo.label) currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
const isTodayOnly = opts.period === 'today' const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
if (isTodayOnly) { if (isTodayOnly) {
scanProjects = todayProjects scanProjects = todayProjects
scanRange = todayRange scanRange = todayRange
} else { } else {
scanProjects = fp(await parseAllSessions(periodInfo.range, 'all')) const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all'))
scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects
scanRange = periodInfo.range scanRange = periodInfo.range
} }
} else { } else {
cache = await loadDailyCache() cache = await loadDailyCache()
const fullProjects = fp(await parseAllSessions(periodInfo.range, pf)) const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf))
const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects
todayProviderData = buildPeriodData(periodInfo.label, fullProjects) todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
currentData = todayProviderData currentData = todayProviderData
scanProjects = fullProjects scanProjects = fullProjects
scanRange = periodInfo.range scanRange = periodInfo.range
} }
if (isAllProviders) {
currentData = buildPeriodData(periodInfo.label, scanProjects)
}
// PROVIDERS // PROVIDERS
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
@ -519,10 +542,11 @@ program
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
const providers: ProviderCost[] = [] const providers: ProviderCost[] = []
if (isAllProviders) { if (isAllProviders) {
const allDaysForProviders = [ const unfilteredProviderDays = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr), ...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []),
...(await getTodayAllDays()).filter(d => d.date === todayStr), ...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr),
] ]
const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays
const providerTotals: Record<string, number> = {} const providerTotals: Record<string, number> = {}
for (const d of allDaysForProviders) { for (const d of allDaysForProviders) {
for (const [name, p] of Object.entries(d.providers)) { for (const [name, p] of Object.entries(d.providers)) {
@ -866,45 +890,6 @@ program
} }
}) })
program
.command('antigravity-hook')
.description('Install or remove exact Antigravity CLI usage capture')
.argument('<action>', 'install or uninstall')
.option('--force', 'Replace an existing custom Antigravity CLI statusLine command')
.action(async (action: string, opts: { force?: boolean }) => {
try {
if (action === 'install') {
const result = await installAntigravityStatusLineHook(!!opts.force)
console.log(result === 'already-installed'
? '\n Antigravity CLI usage capture is already installed.\n'
: '\n Antigravity CLI usage capture installed.\n')
return
}
if (action === 'uninstall') {
const result = await uninstallAntigravityStatusLineHook()
console.log(result === 'not-installed'
? '\n Antigravity CLI usage capture is not installed.\n'
: result === 'restored'
? '\n Antigravity CLI usage capture removed; previous statusLine restored.\n'
: '\n Antigravity CLI usage capture removed.\n')
return
}
console.error('\n Usage: codeburn antigravity-hook <install|uninstall>\n')
process.exit(1)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Antigravity hook failed: ${message}\n`)
process.exit(1)
}
})
program
.command('agy-statusline-hook', { hidden: true })
.description('Internal Antigravity CLI statusLine hook')
.action(async () => {
await runAgyStatusLineHook()
})
program program
.command('currency [code]') .command('currency [code]')
.description('Set display currency (e.g. codeburn currency GBP)') .description('Set display currency (e.g. codeburn currency GBP)')

View file

@ -2012,6 +2012,41 @@ function turnIsInDateRange(turn: ClassifiedTurn, dateRange: DateRange): boolean
return ts >= dateRange.start && ts <= dateRange.end return ts >= dateRange.start && ts <= dateRange.end
} }
function turnDayString(turn: ClassifiedTurn): string | null {
if (turn.assistantCalls.length === 0) return null
const ts = turn.assistantCalls[0]!.timestamp
if (!ts) return null
const d = new Date(ts)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function filterProjectsByDays(projects: ProjectSummary[], days: Set<string>): ProjectSummary[] {
const filtered: ProjectSummary[] = []
for (const project of projects) {
const sessions: SessionSummary[] = []
for (const session of project.sessions) {
const turns = session.turns.filter(turn => {
const ds = turnDayString(turn)
return ds !== null && days.has(ds)
})
if (turns.length === 0) continue
sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory))
}
if (sessions.length === 0) continue
filtered.push({
project: project.project,
projectPath: project.projectPath,
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
}
return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
}
export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] { export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] {
const filtered: ProjectSummary[] = [] const filtered: ProjectSummary[] = []
for (const project of projects) { for (const project of projects) {

View file

@ -169,4 +169,69 @@ describe('codeburn report --format json daily[] one-shot fields (issue #279)', (
await rm(home, { recursive: true, force: true }) await rm(home, { recursive: true, force: true })
} }
}) })
it('filters a single review day with --day', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
// runCli pins TZ=UTC, so these exact boundaries exercise the inclusive
// start/end of the selected calendar day without local-offset drift.
await writeFile(
join(projectDir, 'day-selector.jsonl'),
[
userLine('day-before', '2026-04-09T23:59:00Z'),
assistantNoEditLine('day-before', '2026-04-09T23:59:30Z', 'm-before'),
userLine('day-selected', '2026-04-10T00:00:00Z'),
assistantEditLine('day-selected', '2026-04-10T23:59:59Z', 'm-selected'),
userLine('day-after', '2026-04-11T00:00:00Z'),
assistantNoEditLine('day-after', '2026-04-11T00:00:30Z', 'm-after'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--day', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
period: string
periodKey: string
daily: Array<{ date: string; calls: number; editTurns: number }>
projects: Array<{ sessions: number; calls: number }>
}
expect(report.period).toBe('Day (2026-04-10)')
expect(report.periodKey).toBe('day')
expect(report.daily.map(d => d.date)).toEqual(['2026-04-10'])
expect(report.daily[0]?.calls).toBe(1)
expect(report.daily[0]?.editTurns).toBe(1)
expect(report.projects[0]?.sessions).toBe(1)
expect(report.projects[0]?.calls).toBe(1)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects --day combined with --from/--to', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
try {
const result = runCli([
'--format', 'json',
'--day', '2026-04-10',
'--from', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--day cannot be combined with --from or --to')
} finally {
await rm(home, { recursive: true, force: true })
}
})
}) })

View file

@ -105,4 +105,53 @@ describe('codeburn status --format menubar-json', () => {
await rm(home, { recursive: true, force: true }) await rm(home, { recursive: true, force: true })
} }
}) })
it('filters menubar payloads to a selected review day with --day', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-day-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('before', '2026-04-09T23:58:00Z'),
assistantLine('before', '2026-04-09T23:59:00Z', 'msg-before'),
userLine('selected', '2026-04-10T11:59:00Z'),
assistantLine('selected', '2026-04-10T12:00:00Z', 'msg-selected'),
userLine('after', '2026-04-11T00:00:00Z'),
assistantLine('after', '2026-04-11T00:01:00Z', 'msg-after'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: {
label: string
calls: number
sessions: number
topProjects: Array<{ sessions: number; sessionDetails: Array<{ date: string }> }>
}
}
expect(payload.current.label).toBe('Day (2026-04-10)')
expect(payload.current.calls).toBe(1)
expect(payload.current.sessions).toBe(1)
expect(payload.current.topProjects).toHaveLength(1)
expect(payload.current.topProjects[0]?.sessions).toBe(1)
expect(payload.current.topProjects[0]?.sessionDetails.map(s => s.date)).toEqual(['2026-04-10'])
} finally {
await rm(home, { recursive: true, force: true })
}
})
}) })

View file

@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest' import { afterEach, describe, it, expect, vi } from 'vitest'
import { formatDateRangeLabel, parseDateRangeFlags } from '../src/cli-date.js' import { formatDateRangeLabel, formatDayRangeLabel, parseDateRangeFlags, parseDayFlag, shiftDay } from '../src/cli-date.js'
afterEach(() => {
vi.useRealTimers()
})
describe('parseDateRangeFlags', () => { describe('parseDateRangeFlags', () => {
it('returns null when neither flag is provided', () => { it('returns null when neither flag is provided', () => {
@ -87,3 +91,51 @@ describe('parseDateRangeFlags', () => {
expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today') expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today')
}) })
}) })
describe('parseDayFlag', () => {
it('returns null when no day is provided', () => {
expect(parseDayFlag(undefined)).toBeNull()
})
it('parses an explicit day as local midnight through end of day', () => {
const selected = parseDayFlag('2026-04-10')
expect(selected).not.toBeNull()
expect(selected!.day).toBe('2026-04-10')
expect(selected!.label).toBe('Day (2026-04-10)')
expect(selected!.range.start.getFullYear()).toBe(2026)
expect(selected!.range.start.getMonth()).toBe(3)
expect(selected!.range.start.getDate()).toBe(10)
expect(selected!.range.start.getHours()).toBe(0)
expect(selected!.range.end.getDate()).toBe(10)
expect(selected!.range.end.getHours()).toBe(23)
expect(selected!.range.end.getMinutes()).toBe(59)
expect(selected!.range.end.getSeconds()).toBe(59)
})
it('resolves yesterday as the previous local calendar day after midnight', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 23, 0, 5, 0))
const selected = parseDayFlag('yesterday')
expect(selected!.day).toBe('2026-05-22')
expect(selected!.range.start.getDate()).toBe(22)
expect(selected!.range.end.getDate()).toBe(22)
})
it('supports today and day shifting', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 23, 12, 0, 0))
expect(parseDayFlag('today')!.day).toBe('2026-05-23')
expect(formatDayRangeLabel('2026-05-22')).toBe('Day (2026-05-22)')
expect(shiftDay('2026-05-22', -1)).toBe('2026-05-21')
expect(shiftDay('2026-05-22', 1)).toBe('2026-05-23')
})
it('rejects malformed or overflowing day values', () => {
expect(() => parseDayFlag('May 22')).toThrow('Invalid date format')
expect(() => parseDayFlag('2026-02-31')).toThrow('Invalid date "2026-02-31"')
expect(() => shiftDay('2026-13-01', 1)).toThrow('Invalid date "2026-13-01"')
})
})