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 {
let period: Period
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
@ -21,6 +37,12 @@ struct PayloadCacheKey: Hashable {
final class AppStore {
var selectedProvider: ProviderFilter = .all
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() {
didSet { menubarPeriod.persistAsMenubarDefault() }
}
@ -48,7 +70,6 @@ final class AppStore {
var subscriptionError: String?
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
var capacityEstimates: [String: CapacityEstimate] = [:]
var refreshPauseMessage: String?
var codexUsage: CodexUsage?
var codexError: String?
@ -71,21 +92,32 @@ final class AppStore {
/// from "cache was wiped 10 minutes ago and we still haven't refilled".
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 {
guard let last = lastSuccessByKey[key] else { return .infinity }
return Date().timeIntervalSince(last)
}
private var todayAllKey: PayloadCacheKey {
PayloadCacheKey(period: .today, provider: .all)
PayloadCacheKey(period: .today, provider: .all, day: nil)
}
private var menubarStatusKey: PayloadCacheKey {
PayloadCacheKey(period: menubarPeriod, provider: .all)
PayloadCacheKey(period: menubarPeriod, provider: .all, day: nil)
}
private var currentKey: PayloadCacheKey {
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
}
var payload: MenubarPayload {
@ -111,8 +143,6 @@ final class AppStore {
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? {
cache[menubarStatusKey]?.payload
}
@ -120,7 +150,22 @@ final class AppStore {
/// 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.
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 {
@ -150,7 +195,7 @@ final class AppStore {
let keys = Set([
currentKey,
todayAllKey,
PayloadCacheKey(period: selectedPeriod, provider: .all),
PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays),
])
let staleAges = keys.compactMap { key -> TimeInterval? in
guard let cached = cache[key] else { return nil }
@ -161,7 +206,7 @@ final class AppStore {
}
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 ||
cache[todayAllKey]?.isFresh != true ||
cache[periodAllKey]?.isFresh != true ||
@ -177,8 +222,8 @@ final class AppStore {
}
#if DEBUG
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) {
cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, day: String? = nil, fetchedAt: Date) {
cache[PayloadCacheKey(period: period, provider: provider, day: day)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
}
#endif
@ -190,9 +235,51 @@ final class AppStore {
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
func switchTo(period: Period) {
selectedPeriod = period
selectedDays = []
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
/// 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.
@ -206,27 +293,20 @@ final class AppStore {
resetLoadingState()
let period = selectedPeriod
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 {
if provider == .all {
await refresh(includeOptimize: false, force: true, showLoading: true)
await refresh(key: key, includeOptimize: false, force: true, showLoading: true)
} else {
async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true)
async let all: Void = refreshQuietly(period: period)
async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true)
async let all: Void = refreshQuietly(period: period, day: day)
_ = 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> = []
func resetLoadingState() {
@ -234,6 +314,7 @@ final class AppStore {
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
attemptedKeys.removeAll()
}
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
@discardableResult
@ -271,7 +341,7 @@ final class AppStore {
payloadRefreshGeneration &+= 1
for (key, started) in staleEntries {
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
loadingStartedAtByKey[key] = nil
inFlightKeys.remove(key)
@ -325,10 +395,14 @@ final class AppStore {
}
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()
let key = currentKey
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
if Task.isCancelled { return }
if !force, cache[key]?.isFresh == true { return }
if inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
@ -347,18 +421,22 @@ final class AppStore {
// below filters .infinity) that's just the cold path, not a bug.
let staleSeconds = staleSecondsForKey(key)
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 {
let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration
inFlightKeys.remove(key)
if didShowLoading {
finishLoading(for: key)
}
if abandonedAttempt && cache[key] == nil && lastErrorByKey[key] == nil {
attemptedKeys.remove(key)
}
}
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 {
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
}
if Task.isCancelled {
@ -366,7 +444,7 @@ final class AppStore {
// the silent-no-result path. Without this log, a cancelled
// fetch leaves cache empty + lastError nil and the user sees
// 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
}
// 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.
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
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
}
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
@ -383,10 +461,10 @@ final class AppStore {
lastErrorByKey[key] = nil
} catch {
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 {
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 }
if generationAtStart != payloadRefreshGeneration { return }
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
@ -405,34 +483,34 @@ final class AppStore {
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 {
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).
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
/// 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()
let key = PayloadCacheKey(period: period, provider: .all)
let key = PayloadCacheKey(period: period, provider: .all, day: day)
if !force, cache[key]?.isFresh == true { return }
if inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
attemptedKeys.insert(key)
let cacheDateAtStart = cacheDate
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)
}
defer {
inFlightKeys.remove(key)
}
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 {
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
}
// Same day-rollover guard as refresh(): drop yesterday's payload if
@ -445,7 +523,7 @@ final class AppStore {
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
} 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)
return true
} catch let err as ClaudeSubscriptionService.FetchError {
if Task.isCancelled { return false }
guard gen == claudeRefreshGen else { return false }
applyFetchError(err)
return false
} catch {
if Task.isCancelled { return false }
guard gen == claudeRefreshGen else { return false }
subscriptionError = sanitizeForUI(String(describing: error))
subscriptionLoadState = .failed
@ -584,12 +660,10 @@ final class AppStore {
codexLoadState = .loaded
return true
} catch let err as CodexSubscriptionService.FetchError {
if Task.isCancelled { return false }
guard gen == codexRefreshGen else { return false }
applyCodexFetchError(err)
return false
} catch {
if Task.isCancelled { return false }
guard gen == codexRefreshGen else { return false }
codexError = sanitizeForUI(String(describing: error))
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]
var menubarMetricLabel: String {

View file

@ -47,12 +47,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var manualRefreshTask: Task<Void, Never>?
private var manualRefreshGeneration: UInt64 = 0
private var claudeQuotaRefreshTask: Task<Bool, Never>?
private var claudeQuotaRefreshGeneration: UInt64 = 0
private var codexQuotaRefreshTask: Task<Bool, Never>?
private var codexQuotaRefreshGeneration: UInt64 = 0
private var refreshLoopHeartbeatAt: Date = .distantPast
private var lastLaunchAgentHeartbeatAt: Date = .distantPast
private var forceRefreshBackoff = RefreshBackoff()
func applicationWillFinishLaunching(_ notification: Notification) {
// 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 = nil
manualRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil
@ -191,7 +187,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
manualRefreshTask?.cancel()
manualRefreshTask = nil
manualRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil
@ -298,29 +293,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
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
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
if forceRefreshTask != nil {
@ -328,9 +300,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
forceRefreshTask?.cancel()
forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
store.resetLoadingState()
return true
}
@ -341,16 +311,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
forceRefreshTask = nil
forceRefreshStartedAt = nil
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()
return true
}
@ -361,7 +321,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
if statusPayloadRefreshTask != nil {
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 = nil
statusPayloadRefreshGeneration &+= 1
@ -369,7 +329,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
let elapsed = now.timeIntervalSince(started)
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 = nil
statusPayloadRefreshStartedAt = nil
@ -406,7 +366,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
let now = Date()
_ = clearStaleForceRefreshIfNeeded(now: now)
guard !isForceRefreshPaused(now: now) else { return }
if forceRefreshTask != nil {
refreshStatusPayloadIfNeeded(reason: "blocked force refresh")
}
@ -427,8 +386,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
_ = await quotas
await MainActor.run { [weak self] in
guard let self, self.forceRefreshGeneration == generation else { return }
self.forceRefreshBackoff.recordSuccess()
self.store.clearRefreshPause()
self.forceRefreshTask = nil
self.forceRefreshStartedAt = nil
self.lastRefreshTime = Date()
@ -514,11 +471,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let task = Task { [store] in
await store.refreshSubscriptionReportingSuccess()
}
claudeQuotaRefreshGeneration &+= 1
let generation = claudeQuotaRefreshGeneration
claudeQuotaRefreshTask = task
let result = await task.value
if claudeQuotaRefreshGeneration == generation {
if claudeQuotaRefreshTask != nil {
claudeQuotaRefreshTask = nil
}
return result
@ -531,11 +486,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let task = Task { [store] in
await store.refreshCodexReportingSuccess()
}
codexQuotaRefreshGeneration &+= 1
let generation = codexQuotaRefreshGeneration
codexQuotaRefreshTask = task
let result = await task.value
if codexQuotaRefreshGeneration == generation {
if codexQuotaRefreshTask != nil {
codexQuotaRefreshTask = nil
}
return result
@ -573,12 +526,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
let now = Date()
refreshLoopHeartbeatAt = now
refreshLoopHeartbeatAt = Date()
let hadForceRefreshInFlight = forceRefreshTask != nil
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded(now: now)
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded(now: now)
guard !isForceRefreshPaused(now: now) else { return }
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
let statusPayloadStale = store.needsStatusPayloadRefresh
let sinceLast = Date().timeIntervalSince(lastRefreshTime)
@ -632,9 +583,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
statusPayloadRefreshGeneration &+= 1
pendingRefreshWork?.cancel()
pendingRefreshWork = nil
cancelLiveQuotaRefreshTasks()
forceRefreshBackoff.retryNow(resetStallCount: false)
store.clearRefreshPause()
stopRefreshTimer()
store.resetRefreshState(clearCache: true)
lastRefreshTime = .distantPast
@ -653,8 +601,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.refreshStatusButton()
_ = await quotas
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
self.forceRefreshBackoff.recordSuccess()
self.store.clearRefreshPause()
self.manualRefreshTask = nil
if self.refreshTimer == nil {
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
/// commands through `/bin/zsh -c` anymore.
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 = [
"status",
"--format", "menubar-json",
"--period", period.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 {
subcommand.append("--no-optimize")
}

View file

@ -79,7 +79,7 @@ struct HeatmapSection: View {
} else {
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 .pulse: PulseInsight(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))
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,
let todayCost = store.todayPayload?.current.cost,
todayCost >= store.dailyBudget {
@ -92,7 +93,7 @@ struct HeroSection: View {
private var caption: String {
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

View file

@ -11,14 +11,6 @@ struct MenuBarContent: View {
Divider()
if let message = store.refreshPauseMessage {
RefreshPausedBanner(
message: message,
retry: { refreshNow() }
)
Divider()
}
if showAgentTabs {
AgentTabStrip()
Divider()
@ -32,7 +24,7 @@ struct MenuBarContent: View {
PeriodSegmentedControl()
Divider().opacity(0.5)
if isFilteredEmpty {
EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod)
EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel)
} else {
HeatmapSection()
.padding(.horizontal, 14)
@ -57,22 +49,15 @@ struct MenuBarContent: View {
// error, etc.), surface a retry card instead of leaving the
// user stuck on a perpetual "Loading..." spinner.
if !store.hasCachedData {
if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad {
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
.transition(.opacity)
} else if let err = store.lastError {
if let err = store.lastError {
FetchErrorOverlay(
error: err,
periodLabel: store.selectedPeriod.rawValue,
periodLabel: store.selectionLabel,
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
)
.transition(.opacity)
} else {
FetchErrorOverlay(
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) } }
)
BurnLoadingOverlay(periodLabel: store.selectionLabel)
.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 {
let provider: ProviderFilter
let period: Period
let periodLabel: String
var body: some View {
VStack(spacing: 10) {
Image(systemName: "tray")
.font(.system(size: 26))
.foregroundStyle(.tertiary)
Text("No \(provider.rawValue) data for \(periodPhrase)")
Text("No \(provider.rawValue) data for \(periodLabel)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@ -164,15 +123,6 @@ private struct EmptyProviderState: View {
.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
@ -628,7 +578,11 @@ struct FooterBar: View {
}
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 {
@ -694,17 +648,3 @@ struct FooterBar: View {
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 {
@Environment(AppStore.self) private var store
@State private var showingCalendar = false
var body: some View {
HStack(spacing: 1) {
ForEach(Period.allCases) { period in
let isActive = !store.isDayMode && store.selectedPeriod == period
Button {
store.switchTo(period: period)
} label: {
Text(period.rawValue)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(store.selectedPeriod == period ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
@ -19,10 +21,31 @@ struct PeriodSegmentedControl: View {
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.selectedPeriod == period ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(store.selectedPeriod == period ? 0.06 : 0), radius: 1, y: 0.5)
.fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.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)
.background(
@ -34,3 +57,248 @@ struct PeriodSegmentedControl: View {
.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
}
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 {
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)
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
const end = new Date(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate(),
END_OF_DAY_HOURS,
END_OF_DAY_MINUTES,
END_OF_DAY_SECONDS,
END_OF_DAY_MS,
)
const end = endOfLocalDay(endDate)
if (start > end) {
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) {
case 'today': {
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': {
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: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` }
return { range: dayRangeForDate(start), label: `Yesterday (${toDateString(start)})` }
}
case 'week': {
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 {
return `${from ?? 'all'} to ${to ?? 'today'}`
}

View file

@ -14,7 +14,7 @@ import { dateKey } from './day-aggregator.js'
import { CompareView } from './compare.js'
import { getPlanUsages, type PlanUsage } from './plan-usage.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'
type View = 'dashboard' | 'optimize' | 'compare'
@ -102,6 +102,10 @@ function getPeriodRange(period: Period): { start: Date; end: Date } {
return getDateRange(period).range
}
function getDayRange(day: string): DateRange {
return parseDayFlag(day)!.range
}
function isHeavyPeriod(period: Period): boolean {
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'
return (
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
<Text>
{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></>
: !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></>
: null}
<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>
</>
)}
{!customRange && !isOptimize && (
<>
<Text dimColor> </Text><Text color={ORANGE} bold>d</Text><Text dimColor>{dayMode ? ' exit day' : ' yesterday'}</Text>
</>
)}
{!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}</>
)}
@ -685,15 +696,16 @@ function Row({ wide, width, children }: { wide: boolean; width: number; 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 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 days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
const days = dayMode ? 1 : period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
return (
<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}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{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[]
initialPeriod: Period
initialProvider: string
@ -715,6 +727,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
excludeFilter?: string[]
customRange?: DateRange | null
customRangeLabel?: string
initialDay?: string
}) {
const { exit } = useApp()
const [period, setPeriod] = useState<Period>(initialPeriod)
@ -727,11 +740,13 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const [optimizeLoading, setOptimizeLoading] = useState(false)
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
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
// leaves the optimize view OR the underlying findings change so a long
// findings list never strands the user past the new array length.
const [findingsCursor, setFindingsCursor] = useState(0)
const isCustomRange = customRange != null
const isDayMode = dayDate != null
const isCustomRange = customRange != null && !isDayMode
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
const multipleProviders = detectedProviders.length > 1
@ -743,8 +758,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reloadGenerationRef = useRef(0)
const reloadInFlightRef = useRef(false)
const currentReloadRef = useRef<{ period: Period; provider: string } | null>(null)
const pendingReloadRef = 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; day: string | null } | null>(null)
const findingCount = optimizeResult?.findings.length ?? 0
useEffect(() => {
@ -773,31 +788,31 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
return () => { cancelled = true }
}, [projects])
const reloadData = useCallback(async (p: Period, prov: string) => {
const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null) => {
if (reloadInFlightRef.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
return
}
reloadGenerationRef.current++
pendingReloadRef.current = { period: p, provider: prov }
pendingReloadRef.current = { period: p, provider: prov, day }
return
}
reloadInFlightRef.current = true
currentReloadRef.current = { period: p, provider: prov }
currentReloadRef.current = { period: p, provider: prov, day }
const generation = ++reloadGenerationRef.current
setLoading(true)
setOptimizeLoading(false)
setOptimizeResult(null)
try {
if (isHeavyPeriod(p)) {
if (!day && isHeavyPeriod(p)) {
setProjects([])
setProjectBudgets(new Map())
await nextTick()
if (reloadGenerationRef.current !== generation) return
}
const range = getPeriodRange(p)
const range = day ? getDayRange(day) : getPeriodRange(p)
const data = await parseAllSessions(range, prov)
if (reloadGenerationRef.current !== generation) return
@ -819,11 +834,15 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const pending = pendingReloadRef.current
pendingReloadRef.current = null
if (pending) {
void reloadData(pending.period, pending.provider)
void reloadData(pending.period, pending.provider, pending.day)
}
}
}, [projectFilter, excludeFilter])
const currentRange = useCallback(() => {
return dayDate ? getDayRange(dayDate) : getPeriodRange(period)
}, [dayDate, period])
const loadOptimizeResult = useCallback(async () => {
if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return
setView('optimize')
@ -833,44 +852,63 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const generation = reloadGenerationRef.current
setOptimizeLoading(true)
try {
const result = await scanAndDetect(projects, getPeriodRange(period))
const result = await scanAndDetect(projects, currentRange())
if (reloadGenerationRef.current === generation) setOptimizeResult(result)
} catch (error) {
console.error(error)
} finally {
if (reloadGenerationRef.current === generation) setOptimizeLoading(false)
}
}, [optimizeAvailable, projects, period, optimizeLoading, optimizeResult])
}, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
if (isHeavyPeriod(period)) return
const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000)
if (!dayDate && isHeavyPeriod(period)) return
const id = setInterval(() => { reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000)
return () => clearInterval(id)
}, [refreshSeconds, period, activeProvider, reloadData])
}, [refreshSeconds, period, activeProvider, dayDate, reloadData])
const switchPeriod = useCallback((np: Period) => {
if (np === period) return
if (np === period && !dayDate) return
// Clear projects + flip loading synchronously so the dashboard never
// renders the new period label over the old period's numbers between
// setPeriod() and the reloadData() promise resolving. Without this,
// there's a frame-to-hundreds-of-ms window where users saw wrong
// figures captioned with the new period.
setPeriod(np)
setDayDate(null)
setProjects([])
setLoading(true)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600)
}, [period, activeProvider, reloadData])
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider, null) }, 600)
}, [period, activeProvider, dayDate, reloadData])
const switchPeriodImmediate = useCallback(async (np: Period) => {
if (np === period) return
if (np === period && !dayDate) return
setPeriod(np)
setDayDate(null)
setProjects([])
setLoading(true)
if (debounceRef.current) clearTimeout(debounceRef.current)
await reloadData(np, activeProvider)
}, [period, activeProvider, reloadData])
await reloadData(np, activeProvider, null)
}, [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) => {
if (input === 'q') { exit(); return }
@ -881,6 +919,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE)
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 }
return
}
if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); 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]
setActiveProvider(next); setView('dashboard')
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
// compare view is mounted; the compare view re-aggregates from
// `projects` and would visibly change underneath the user without any
// affordance back to the dashboard. Press `b` or Esc to return first.
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
// period would silently abandon the user's explicit range and reload
// standard period data; the period tab strip is hidden in this mode so
// users have no expectation that 1-5 should do anything.
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)
if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % 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')
})
const headerLabel = customRangeLabel ?? PERIOD_LABELS[period]
const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period]
if (loading || optimizeLoading) {
return (
<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} />}
{view === 'compare'
? <Box flexDirection="column" paddingX={2} paddingY={1}>
@ -928,21 +990,30 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
: view === 'optimize'
? <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>}
{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>
)
}
return (
<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} />}
{view === 'compare'
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
: view === 'optimize' && optimizeResult
? <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} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} />}
: <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} 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>
)
}
@ -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 { dashWidth } = getLayout(columns)
return (
<Box flexDirection="column" width={dashWidth}>
<PeriodTabs active={period} />
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} />
{dayMode ? <DayBanner label={label ?? PERIOD_LABELS[period]} width={dashWidth} /> : <PeriodTabs active={period} />}
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} label={label} dayMode={dayMode} />
</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()
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 planUsages = await getPlanUsages()
const isTTY = process.stdin.isTTY && process.stdout.isTTY
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
patchStdoutForWindows()
if (isTTY) {
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()
} 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()
}
}

View file

@ -3,7 +3,7 @@ import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.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 { renderStatusBar } from './format.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 { aggregateModelEfficiency } from './model-efficiency.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 { renderCompare } from './compare.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 { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
@ -361,6 +356,7 @@ program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.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('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
@ -371,7 +367,12 @@ program
.action(async (opts) => {
assertFormat(opts.format, ['tui', 'json'], 'report')
let customRange: DateRange | null = null
let daySelection: ReturnType<typeof parseDayFlag> = null
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)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@ -382,21 +383,23 @@ program
const period = toPeriod(opts.period)
if (opts.format === 'json') {
await loadPricing()
if (customRange) {
const label = formatDateRangeLabel(opts.from, opts.to)
if (daySelection || customRange) {
const range = daySelection?.range ?? customRange!
const label = daySelection?.label ?? formatDateRangeLabel(opts.from, opts.to)
const periodKey = daySelection ? 'day' : 'custom'
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
await parseAllSessions(range, opts.provider),
opts.project,
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 {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
}
return
}
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 {
@ -447,6 +450,10 @@ program
.option('--project <name>', 'Show only 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('--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)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
@ -454,7 +461,14 @@ program
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
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 todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
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 rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
const isAllProviders = pf === 'all'
let todayAllProjects: ProjectSummary[] | null = null
@ -491,26 +506,34 @@ program
cache = await hydrateCache()
const todayProjects = await getTodayAllProjects()
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 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)
const isTodayOnly = opts.period === 'today'
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
if (isTodayOnly) {
scanProjects = todayProjects
scanRange = todayRange
} 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
}
} else {
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)
currentData = todayProviderData
scanProjects = fullProjects
scanRange = periodInfo.range
}
if (isAllProviders) {
currentData = buildPeriodData(periodInfo.label, scanProjects)
}
// PROVIDERS
// 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 providers: ProviderCost[] = []
if (isAllProviders) {
const allDaysForProviders = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
...(await getTodayAllDays()).filter(d => d.date === todayStr),
const unfilteredProviderDays = [
...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []),
...(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> = {}
for (const d of allDaysForProviders) {
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
.command('currency [code]')
.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
}
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[] {
const filtered: ProjectSummary[] = []
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 })
}
})
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 })
}
})
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 { formatDateRangeLabel, parseDateRangeFlags } from '../src/cli-date.js'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { formatDateRangeLabel, formatDayRangeLabel, parseDateRangeFlags, parseDayFlag, shiftDay } from '../src/cli-date.js'
afterEach(() => {
vi.useRealTimers()
})
describe('parseDateRangeFlags', () => {
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')
})
})
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"')
})
})