diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 2c68a02a..24883378 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -14,6 +14,22 @@ struct CachedPayload { struct PayloadCacheKey: Hashable { let period: Period let provider: ProviderFilter + let day: String? + let days: Set + + init(period: Period, provider: ProviderFilter, day: String? = nil, days: Set = []) { + 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 = [] + + 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) { + 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 = [] 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 { diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index f299e729..bad6eed3 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -47,12 +47,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var manualRefreshTask: Task? private var manualRefreshGeneration: UInt64 = 0 private var claudeQuotaRefreshTask: Task? - private var claudeQuotaRefreshGeneration: UInt64 = 0 private var codexQuotaRefreshTask: Task? - 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() diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index 4b0083c0..7ada8e53 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -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 = [], 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") } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index f5d12705..f528cd65 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -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" } - diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index 50d07eeb..8d70b424 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -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 diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index d67f3fdc..0e89bb2f 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -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) - } -} diff --git a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift index 065e363d..5c2307d4 100644 --- a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift +++ b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift @@ -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 = [] + + 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 + } +} + diff --git a/src/cli-date.ts b/src/cli-date.ts index 250884bd..9740b6c5 100644 --- a/src/cli-date.ts +++ b/src/cli-date.ts @@ -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; 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'}` } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index a70eea20..f0d2d2eb 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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 ( {isOptimize ? <>b back j/k scroll - : !customRange + : dayMode + ? <>{'<'}{'>'} day + : !customRange ? <>{'<'}{'>'} switch : null} q quit @@ -668,6 +674,11 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, 5 6 months )} + {!customRange && !isOptimize && ( + <> + d{dayMode ? ' exit day' : ' yesterday'} + + )} {!isOptimize && optimizeAvailable && ( <> o optimize{findingCount != null && findingCount > 0 ? ({findingCount}) : 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; planUsages?: PlanUsage[] }) { +function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) { const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns) const isCursor = activeProvider === 'cursor' - if (projects.length === 0) return No usage data found for {PERIOD_LABELS[period]}. + const activeLabel = label ?? PERIOD_LABELS[period] + if (projects.length === 0) return No usage data found for {activeLabel}. 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 ( - + {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(initialPeriod) @@ -727,11 +740,13 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, const [optimizeLoading, setOptimizeLoading] = useState(false) const [projectBudgets, setProjectBudgets] = useState>(new Map()) const [planUsages, setPlanUsages] = useState(initialPlanUsages ?? []) + const [dayDate, setDayDate] = useState(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 | 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 ( - {!isCustomRange && } + {!isCustomRange && !isDayMode && } + {isDayMode && } {isCustomRange && } {view === 'compare' ? @@ -928,21 +990,30 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, : view === 'optimize' ? Scanning {headerLabel}... : Loading {headerLabel}...} - {view !== 'compare' && } + {view !== 'compare' && } ) } return ( - {!isCustomRange && } + {!isCustomRange && !isDayMode && } + {isDayMode && } {isCustomRange && } {view === 'compare' ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } - {view !== 'compare' && } + : } + {view !== 'compare' && } + + ) +} + +function DayBanner({ label, width }: { label: string; width: number }) { + return ( + + {label} ) } @@ -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 ( - - + {dayMode ? : } + ) } -export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string): Promise { +export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise { 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( - + ) await waitUntilExit() } else { - const { unmount } = render(, { patchConsole: false }) + const { unmount } = render(, { patchConsole: false }) unmount() } } diff --git a/src/main.ts b/src/main.ts index 754a24cd..e75a9ec7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 ', 'Starting period: today, week, 30days, month, all', 'week') + .option('--day ', 'Single day to review (YYYY-MM-DD, today, or yesterday). Overrides --period when set') .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') .option('--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 = 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 ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today') + .option('--day ', 'Single day for menubar-json (YYYY-MM-DD, today, or yesterday). Overrides --period when set') + .option('--from ', 'Start date (YYYY-MM-DD) for custom range') + .option('--to ', 'End date (YYYY-MM-DD) for custom range') + .option('--days ', '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 = {} 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('', '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 \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)') diff --git a/src/parser.ts b/src/parser.ts index 91a9e524..a8e6d4a3 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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): 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) { diff --git a/tests/cli-json-daily.test.ts b/tests/cli-json-daily.test.ts index 173878f8..34ad500f 100644 --- a/tests/cli-json-daily.test.ts +++ b/tests/cli-json-daily.test.ts @@ -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 }) + } + }) }) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index 74a16f9b..7252938f 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -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 }) + } + }) }) diff --git a/tests/date-range-filter.test.ts b/tests/date-range-filter.test.ts index 5c6f1066..e21b906e 100644 --- a/tests/date-range-filter.test.ts +++ b/tests/date-range-filter.test.ts @@ -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"') + }) +})