mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-07 23:54:45 +00:00
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:
parent
aa69857e30
commit
5800179da9
14 changed files with 859 additions and 316 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
src/main.ts
105
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 <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)')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue