mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-10 00:54:44 +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 {
|
struct PayloadCacheKey: Hashable {
|
||||||
let period: Period
|
let period: Period
|
||||||
let provider: ProviderFilter
|
let provider: ProviderFilter
|
||||||
|
let day: String?
|
||||||
|
let days: Set<String>
|
||||||
|
|
||||||
|
init(period: Period, provider: ProviderFilter, day: String? = nil, days: Set<String> = []) {
|
||||||
|
self.period = period
|
||||||
|
self.provider = provider
|
||||||
|
self.day = days.count <= 1 ? (day ?? days.first) : nil
|
||||||
|
self.days = days.count > 1 ? days : []
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
if !days.isEmpty, let first = days.min(), let last = days.max() {
|
||||||
|
return "\(first)..\(last)"
|
||||||
|
}
|
||||||
|
return day.map { "Day(\($0))" } ?? period.rawValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
@ -21,6 +37,12 @@ struct PayloadCacheKey: Hashable {
|
||||||
final class AppStore {
|
final class AppStore {
|
||||||
var selectedProvider: ProviderFilter = .all
|
var selectedProvider: ProviderFilter = .all
|
||||||
var selectedPeriod: Period = .today
|
var selectedPeriod: Period = .today
|
||||||
|
var selectedDays: Set<String> = []
|
||||||
|
|
||||||
|
var selectedDay: String? {
|
||||||
|
guard selectedDays.count == 1 else { return selectedDays.min() }
|
||||||
|
return selectedDays.first
|
||||||
|
}
|
||||||
private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() {
|
private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() {
|
||||||
didSet { menubarPeriod.persistAsMenubarDefault() }
|
didSet { menubarPeriod.persistAsMenubarDefault() }
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +70,6 @@ final class AppStore {
|
||||||
var subscriptionError: String?
|
var subscriptionError: String?
|
||||||
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
|
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
|
||||||
var capacityEstimates: [String: CapacityEstimate] = [:]
|
var capacityEstimates: [String: CapacityEstimate] = [:]
|
||||||
var refreshPauseMessage: String?
|
|
||||||
|
|
||||||
var codexUsage: CodexUsage?
|
var codexUsage: CodexUsage?
|
||||||
var codexError: String?
|
var codexError: String?
|
||||||
|
|
@ -71,21 +92,32 @@ final class AppStore {
|
||||||
/// from "cache was wiped 10 minutes ago and we still haven't refilled".
|
/// from "cache was wiped 10 minutes ago and we still haven't refilled".
|
||||||
private var lastSuccessByKey: [PayloadCacheKey: Date] = [:]
|
private var lastSuccessByKey: [PayloadCacheKey: Date] = [:]
|
||||||
|
|
||||||
|
static let dayFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.timeZone = .current
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func dayString(from date: Date) -> String {
|
||||||
|
dayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval {
|
private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval {
|
||||||
guard let last = lastSuccessByKey[key] else { return .infinity }
|
guard let last = lastSuccessByKey[key] else { return .infinity }
|
||||||
return Date().timeIntervalSince(last)
|
return Date().timeIntervalSince(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var todayAllKey: PayloadCacheKey {
|
private var todayAllKey: PayloadCacheKey {
|
||||||
PayloadCacheKey(period: .today, provider: .all)
|
PayloadCacheKey(period: .today, provider: .all, day: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var menubarStatusKey: PayloadCacheKey {
|
private var menubarStatusKey: PayloadCacheKey {
|
||||||
PayloadCacheKey(period: menubarPeriod, provider: .all)
|
PayloadCacheKey(period: menubarPeriod, provider: .all, day: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentKey: PayloadCacheKey {
|
private var currentKey: PayloadCacheKey {
|
||||||
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
|
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload: MenubarPayload {
|
var payload: MenubarPayload {
|
||||||
|
|
@ -111,8 +143,6 @@ final class AppStore {
|
||||||
cache[menubarStatusKey]?.isFresh != true
|
cache[menubarStatusKey]?.isFresh != true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All-provider payload for the user-selected menubar status metric. The
|
|
||||||
/// popover's visible period/provider can differ from this setting.
|
|
||||||
var menubarPayload: MenubarPayload? {
|
var menubarPayload: MenubarPayload? {
|
||||||
cache[menubarStatusKey]?.payload
|
cache[menubarStatusKey]?.payload
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +150,22 @@ final class AppStore {
|
||||||
/// All-provider payload for the selected period. Used by the tab strip to show
|
/// All-provider payload for the selected period. Used by the tab strip to show
|
||||||
/// per-provider costs that match the active period, not just today.
|
/// per-provider costs that match the active period, not just today.
|
||||||
var periodAllPayload: MenubarPayload? {
|
var periodAllPayload: MenubarPayload? {
|
||||||
cache[PayloadCacheKey(period: selectedPeriod, provider: .all)]?.payload
|
cache[PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)]?.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDayMode: Bool {
|
||||||
|
!selectedDays.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectionLabel: String {
|
||||||
|
if selectedDays.count > 1, let first = selectedDays.min(), let last = selectedDays.max() {
|
||||||
|
return "\(selectedDays.count) days (\(first) .. \(last))"
|
||||||
|
}
|
||||||
|
return selectedDay.map { "Day (\($0))" } ?? selectedPeriod.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var trendPeriod: Period {
|
||||||
|
isDayMode ? .today : selectedPeriod
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasCachedData: Bool {
|
var hasCachedData: Bool {
|
||||||
|
|
@ -150,7 +195,7 @@ final class AppStore {
|
||||||
let keys = Set([
|
let keys = Set([
|
||||||
currentKey,
|
currentKey,
|
||||||
todayAllKey,
|
todayAllKey,
|
||||||
PayloadCacheKey(period: selectedPeriod, provider: .all),
|
PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays),
|
||||||
])
|
])
|
||||||
let staleAges = keys.compactMap { key -> TimeInterval? in
|
let staleAges = keys.compactMap { key -> TimeInterval? in
|
||||||
guard let cached = cache[key] else { return nil }
|
guard let cached = cache[key] else { return nil }
|
||||||
|
|
@ -161,7 +206,7 @@ final class AppStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
var needsInteractivePayloadRefresh: Bool {
|
var needsInteractivePayloadRefresh: Bool {
|
||||||
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
|
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)
|
||||||
return cache[currentKey]?.isFresh != true ||
|
return cache[currentKey]?.isFresh != true ||
|
||||||
cache[todayAllKey]?.isFresh != true ||
|
cache[todayAllKey]?.isFresh != true ||
|
||||||
cache[periodAllKey]?.isFresh != true ||
|
cache[periodAllKey]?.isFresh != true ||
|
||||||
|
|
@ -177,8 +222,8 @@ final class AppStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) {
|
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, day: String? = nil, fetchedAt: Date) {
|
||||||
cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
|
cache[PayloadCacheKey(period: period, provider: provider, day: day)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
@ -190,9 +235,51 @@ final class AppStore {
|
||||||
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
|
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
|
||||||
func switchTo(period: Period) {
|
func switchTo(period: Period) {
|
||||||
selectedPeriod = period
|
selectedPeriod = period
|
||||||
|
selectedDays = []
|
||||||
startInteractiveSelectionRefresh()
|
startInteractiveSelectionRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func switchToYesterday() {
|
||||||
|
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
|
||||||
|
switchTo(day: yesterday)
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchTo(day: Date) {
|
||||||
|
let clamped = min(Calendar.current.startOfDay(for: day), Calendar.current.startOfDay(for: Date()))
|
||||||
|
selectedDays = [Self.dayString(from: clamped)]
|
||||||
|
startInteractiveSelectionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchTo(days: Set<String>) {
|
||||||
|
selectedDays = days
|
||||||
|
startInteractiveSelectionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shiftSelectedDay(by delta: Int) {
|
||||||
|
let base = selectedDayDate ?? Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
|
||||||
|
let shifted = Calendar.current.date(byAdding: .day, value: delta, to: base) ?? base
|
||||||
|
switchTo(day: shifted)
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedDayDate: Date? {
|
||||||
|
guard let selectedDay else { return nil }
|
||||||
|
return Self.dayFormatter.date(from: selectedDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
var canShiftSelectedDayForward: Bool {
|
||||||
|
guard let selectedDayDate else { return false }
|
||||||
|
return Calendar.current.startOfDay(for: selectedDayDate) < Calendar.current.startOfDay(for: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMenubarPeriod(_ period: Period) {
|
||||||
|
guard Period.menubarMetricCases.contains(period) else { return }
|
||||||
|
guard menubarPeriod != period else { return }
|
||||||
|
menubarPeriod = period
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.refreshQuietly(period: period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
|
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
|
||||||
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
|
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
|
||||||
/// in parallel so the tab strip costs stay in sync with the hero.
|
/// in parallel so the tab strip costs stay in sync with the hero.
|
||||||
|
|
@ -206,27 +293,20 @@ final class AppStore {
|
||||||
resetLoadingState()
|
resetLoadingState()
|
||||||
let period = selectedPeriod
|
let period = selectedPeriod
|
||||||
let provider = selectedProvider
|
let provider = selectedProvider
|
||||||
lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil
|
let day = selectedDay
|
||||||
|
let key = PayloadCacheKey(period: period, provider: provider, day: day)
|
||||||
|
lastErrorByKey[key] = nil
|
||||||
switchTask = Task {
|
switchTask = Task {
|
||||||
if provider == .all {
|
if provider == .all {
|
||||||
await refresh(includeOptimize: false, force: true, showLoading: true)
|
await refresh(key: key, includeOptimize: false, force: true, showLoading: true)
|
||||||
} else {
|
} else {
|
||||||
async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true)
|
async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true)
|
||||||
async let all: Void = refreshQuietly(period: period)
|
async let all: Void = refreshQuietly(period: period, day: day)
|
||||||
_ = await (main, all)
|
_ = await (main, all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMenubarPeriod(_ period: Period) {
|
|
||||||
guard Period.menubarMetricCases.contains(period) else { return }
|
|
||||||
guard menubarPeriod != period else { return }
|
|
||||||
menubarPeriod = period
|
|
||||||
Task { [weak self] in
|
|
||||||
await self?.refreshQuietly(period: period)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var inFlightKeys: Set<PayloadCacheKey> = []
|
private var inFlightKeys: Set<PayloadCacheKey> = []
|
||||||
|
|
||||||
func resetLoadingState() {
|
func resetLoadingState() {
|
||||||
|
|
@ -234,6 +314,7 @@ final class AppStore {
|
||||||
loadingCountsByKey.removeAll()
|
loadingCountsByKey.removeAll()
|
||||||
loadingStartedAtByKey.removeAll()
|
loadingStartedAtByKey.removeAll()
|
||||||
inFlightKeys.removeAll()
|
inFlightKeys.removeAll()
|
||||||
|
attemptedKeys.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetRefreshState(clearCache: Bool = false) {
|
func resetRefreshState(clearCache: Bool = false) {
|
||||||
|
|
@ -247,17 +328,6 @@ final class AppStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseAutomaticRefresh(until: Date, consecutiveStalls: Int) {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.timeStyle = .short
|
|
||||||
formatter.dateStyle = .none
|
|
||||||
refreshPauseMessage = "Refresh paused until \(formatter.string(from: until)) after \(consecutiveStalls) stalled attempts. Click retry to resume."
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearRefreshPause() {
|
|
||||||
refreshPauseMessage = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private let loadingWatchdogSeconds: TimeInterval = 60
|
private let loadingWatchdogSeconds: TimeInterval = 60
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|
@ -271,7 +341,7 @@ final class AppStore {
|
||||||
payloadRefreshGeneration &+= 1
|
payloadRefreshGeneration &+= 1
|
||||||
for (key, started) in staleEntries {
|
for (key, started) in staleEntries {
|
||||||
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
|
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
|
||||||
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
|
Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue)
|
||||||
loadingCountsByKey[key] = nil
|
loadingCountsByKey[key] = nil
|
||||||
loadingStartedAtByKey[key] = nil
|
loadingStartedAtByKey[key] = nil
|
||||||
inFlightKeys.remove(key)
|
inFlightKeys.remove(key)
|
||||||
|
|
@ -325,10 +395,14 @@ final class AppStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
|
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
|
||||||
|
await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
|
||||||
invalidateStaleDayCache()
|
invalidateStaleDayCache()
|
||||||
let key = currentKey
|
|
||||||
let cacheDateAtStart = cacheDate
|
let cacheDateAtStart = cacheDate
|
||||||
let generationAtStart = payloadRefreshGeneration
|
let generationAtStart = payloadRefreshGeneration
|
||||||
|
if Task.isCancelled { return }
|
||||||
if !force, cache[key]?.isFresh == true { return }
|
if !force, cache[key]?.isFresh == true { return }
|
||||||
if inFlightKeys.contains(key) { return }
|
if inFlightKeys.contains(key) { return }
|
||||||
inFlightKeys.insert(key)
|
inFlightKeys.insert(key)
|
||||||
|
|
@ -347,18 +421,22 @@ final class AppStore {
|
||||||
// below filters .infinity) — that's just the cold path, not a bug.
|
// below filters .infinity) — that's just the cold path, not a bug.
|
||||||
let staleSeconds = staleSecondsForKey(key)
|
let staleSeconds = staleSecondsForKey(key)
|
||||||
if staleSeconds.isFinite, staleSeconds > 120 {
|
if staleSeconds.isFinite, staleSeconds > 120 {
|
||||||
NSLog("CodeBurn: refresh attempt for stale key \(key.period.rawValue)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago")
|
NSLog("CodeBurn: refresh attempt for stale key \(key.label)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago")
|
||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
|
let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration
|
||||||
inFlightKeys.remove(key)
|
inFlightKeys.remove(key)
|
||||||
if didShowLoading {
|
if didShowLoading {
|
||||||
finishLoading(for: key)
|
finishLoading(for: key)
|
||||||
}
|
}
|
||||||
|
if abandonedAttempt && cache[key] == nil && lastErrorByKey[key] == nil {
|
||||||
|
attemptedKeys.remove(key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
|
let fresh = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: includeOptimize)
|
||||||
if generationAtStart != payloadRefreshGeneration {
|
if generationAtStart != payloadRefreshGeneration {
|
||||||
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
|
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
|
|
@ -366,7 +444,7 @@ final class AppStore {
|
||||||
// the silent-no-result path. Without this log, a cancelled
|
// the silent-no-result path. Without this log, a cancelled
|
||||||
// fetch leaves cache empty + lastError nil and the user sees
|
// fetch leaves cache empty + lastError nil and the user sees
|
||||||
// perpetual loading with nothing in the diagnostics.
|
// perpetual loading with nothing in the diagnostics.
|
||||||
NSLog("CodeBurn: fetch for \(key.period.rawValue)/\(key.provider.rawValue) cancelled before result was applied")
|
NSLog("CodeBurn: fetch for \(key.label)/\(key.provider.rawValue) cancelled before result was applied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Day-rollover race guard: if the calendar date changed during the
|
// Day-rollover race guard: if the calendar date changed during the
|
||||||
|
|
@ -375,7 +453,7 @@ final class AppStore {
|
||||||
// tick will refetch with today's data.
|
// tick will refetch with today's data.
|
||||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||||
invalidateStaleDayCache()
|
invalidateStaleDayCache()
|
||||||
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch")
|
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — calendar rolled mid-fetch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||||
|
|
@ -383,10 +461,10 @@ final class AppStore {
|
||||||
lastErrorByKey[key] = nil
|
lastErrorByKey[key] = nil
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled { return }
|
if Task.isCancelled { return }
|
||||||
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
|
NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)")
|
||||||
if includeOptimize, cache[key] == nil {
|
if includeOptimize, cache[key] == nil {
|
||||||
do {
|
do {
|
||||||
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
|
let fallback = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: false)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
if generationAtStart != payloadRefreshGeneration { return }
|
if generationAtStart != payloadRefreshGeneration { return }
|
||||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||||
|
|
@ -405,34 +483,34 @@ final class AppStore {
|
||||||
lastErrorByKey[key] = String(describing: error)
|
lastErrorByKey[key] = String(describing: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
|
let allKey = PayloadCacheKey(period: key.period, provider: .all, day: key.day)
|
||||||
if key != allKey, cache[allKey]?.isFresh != true {
|
if key != allKey, cache[allKey]?.isFresh != true {
|
||||||
await refreshQuietly(period: selectedPeriod)
|
await refreshQuietly(period: key.period, day: key.day)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
|
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
|
||||||
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
|
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
|
||||||
/// Always uses the .all provider since the menubar badge shows total spend.
|
/// Always uses the .all provider since the menubar badge shows total spend.
|
||||||
func refreshQuietly(period: Period, force: Bool = false) async {
|
func refreshQuietly(period: Period, day: String? = nil, force: Bool = false) async {
|
||||||
invalidateStaleDayCache()
|
invalidateStaleDayCache()
|
||||||
let key = PayloadCacheKey(period: period, provider: .all)
|
let key = PayloadCacheKey(period: period, provider: .all, day: day)
|
||||||
if !force, cache[key]?.isFresh == true { return }
|
if !force, cache[key]?.isFresh == true { return }
|
||||||
if inFlightKeys.contains(key) { return }
|
if inFlightKeys.contains(key) { return }
|
||||||
inFlightKeys.insert(key)
|
inFlightKeys.insert(key)
|
||||||
attemptedKeys.insert(key)
|
attemptedKeys.insert(key)
|
||||||
let cacheDateAtStart = cacheDate
|
let cacheDateAtStart = cacheDate
|
||||||
let generationAtStart = payloadRefreshGeneration
|
let generationAtStart = payloadRefreshGeneration
|
||||||
if period == .today, let age = todayPayloadAgeSeconds, age > 120 {
|
if day == nil && period == .today, let age = todayPayloadAgeSeconds, age > 120 {
|
||||||
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
|
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
|
||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
inFlightKeys.remove(key)
|
inFlightKeys.remove(key)
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
|
let fresh = try await DataClient.fetch(period: period, day: day, provider: .all, includeOptimize: false)
|
||||||
if generationAtStart != payloadRefreshGeneration {
|
if generationAtStart != payloadRefreshGeneration {
|
||||||
NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch")
|
NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Same day-rollover guard as refresh(): drop yesterday's payload if
|
// Same day-rollover guard as refresh(): drop yesterday's payload if
|
||||||
|
|
@ -445,7 +523,7 @@ final class AppStore {
|
||||||
lastSuccessByKey[key] = Date()
|
lastSuccessByKey[key] = Date()
|
||||||
lastErrorByKey[key] = nil
|
lastErrorByKey[key] = nil
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
|
NSLog("CodeBurn: quiet refresh failed for \(key.label): \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,12 +589,10 @@ final class AppStore {
|
||||||
await captureSnapshots(for: usage)
|
await captureSnapshots(for: usage)
|
||||||
return true
|
return true
|
||||||
} catch let err as ClaudeSubscriptionService.FetchError {
|
} catch let err as ClaudeSubscriptionService.FetchError {
|
||||||
if Task.isCancelled { return false }
|
|
||||||
guard gen == claudeRefreshGen else { return false }
|
guard gen == claudeRefreshGen else { return false }
|
||||||
applyFetchError(err)
|
applyFetchError(err)
|
||||||
return false
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled { return false }
|
|
||||||
guard gen == claudeRefreshGen else { return false }
|
guard gen == claudeRefreshGen else { return false }
|
||||||
subscriptionError = sanitizeForUI(String(describing: error))
|
subscriptionError = sanitizeForUI(String(describing: error))
|
||||||
subscriptionLoadState = .failed
|
subscriptionLoadState = .failed
|
||||||
|
|
@ -584,12 +660,10 @@ final class AppStore {
|
||||||
codexLoadState = .loaded
|
codexLoadState = .loaded
|
||||||
return true
|
return true
|
||||||
} catch let err as CodexSubscriptionService.FetchError {
|
} catch let err as CodexSubscriptionService.FetchError {
|
||||||
if Task.isCancelled { return false }
|
|
||||||
guard gen == codexRefreshGen else { return false }
|
guard gen == codexRefreshGen else { return false }
|
||||||
applyCodexFetchError(err)
|
applyCodexFetchError(err)
|
||||||
return false
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled { return false }
|
|
||||||
guard gen == codexRefreshGen else { return false }
|
guard gen == codexRefreshGen else { return false }
|
||||||
codexError = sanitizeForUI(String(describing: error))
|
codexError = sanitizeForUI(String(describing: error))
|
||||||
codexLoadState = .failed
|
codexLoadState = .failed
|
||||||
|
|
@ -1017,8 +1091,6 @@ enum Period: String, CaseIterable, Identifiable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status item metrics intentionally stay to the coarse Settings choices.
|
|
||||||
/// The popover still offers 30 Days, but it is not a persisted status metric.
|
|
||||||
static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all]
|
static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all]
|
||||||
|
|
||||||
var menubarMetricLabel: String {
|
var menubarMetricLabel: String {
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
private var manualRefreshTask: Task<Void, Never>?
|
private var manualRefreshTask: Task<Void, Never>?
|
||||||
private var manualRefreshGeneration: UInt64 = 0
|
private var manualRefreshGeneration: UInt64 = 0
|
||||||
private var claudeQuotaRefreshTask: Task<Bool, Never>?
|
private var claudeQuotaRefreshTask: Task<Bool, Never>?
|
||||||
private var claudeQuotaRefreshGeneration: UInt64 = 0
|
|
||||||
private var codexQuotaRefreshTask: Task<Bool, Never>?
|
private var codexQuotaRefreshTask: Task<Bool, Never>?
|
||||||
private var codexQuotaRefreshGeneration: UInt64 = 0
|
|
||||||
private var refreshLoopHeartbeatAt: Date = .distantPast
|
private var refreshLoopHeartbeatAt: Date = .distantPast
|
||||||
private var lastLaunchAgentHeartbeatAt: Date = .distantPast
|
private var lastLaunchAgentHeartbeatAt: Date = .distantPast
|
||||||
private var forceRefreshBackoff = RefreshBackoff()
|
|
||||||
|
|
||||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||||
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
|
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
|
||||||
|
|
@ -171,7 +168,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
manualRefreshTask?.cancel()
|
manualRefreshTask?.cancel()
|
||||||
manualRefreshTask = nil
|
manualRefreshTask = nil
|
||||||
manualRefreshGeneration &+= 1
|
manualRefreshGeneration &+= 1
|
||||||
cancelLiveQuotaRefreshTasks()
|
|
||||||
statusPayloadRefreshTask?.cancel()
|
statusPayloadRefreshTask?.cancel()
|
||||||
statusPayloadRefreshTask = nil
|
statusPayloadRefreshTask = nil
|
||||||
statusPayloadRefreshStartedAt = nil
|
statusPayloadRefreshStartedAt = nil
|
||||||
|
|
@ -191,7 +187,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
manualRefreshTask?.cancel()
|
manualRefreshTask?.cancel()
|
||||||
manualRefreshTask = nil
|
manualRefreshTask = nil
|
||||||
manualRefreshGeneration &+= 1
|
manualRefreshGeneration &+= 1
|
||||||
cancelLiveQuotaRefreshTasks()
|
|
||||||
statusPayloadRefreshTask?.cancel()
|
statusPayloadRefreshTask?.cancel()
|
||||||
statusPayloadRefreshTask = nil
|
statusPayloadRefreshTask = nil
|
||||||
statusPayloadRefreshStartedAt = nil
|
statusPayloadRefreshStartedAt = nil
|
||||||
|
|
@ -298,29 +293,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
|
|
||||||
private var lastRefreshTime: Date = .distantPast
|
private var lastRefreshTime: Date = .distantPast
|
||||||
|
|
||||||
private func cancelLiveQuotaRefreshTasks() {
|
|
||||||
claudeQuotaRefreshTask?.cancel()
|
|
||||||
claudeQuotaRefreshTask = nil
|
|
||||||
claudeQuotaRefreshGeneration &+= 1
|
|
||||||
codexQuotaRefreshTask?.cancel()
|
|
||||||
codexQuotaRefreshTask = nil
|
|
||||||
codexQuotaRefreshGeneration &+= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isForceRefreshPaused(now: Date = Date()) -> Bool {
|
|
||||||
if forceRefreshBackoff.isPaused(now: now) {
|
|
||||||
if let until = forceRefreshBackoff.pausedUntil {
|
|
||||||
store.pauseAutomaticRefresh(
|
|
||||||
until: until,
|
|
||||||
consecutiveStalls: forceRefreshBackoff.consecutiveStalls
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
store.clearRefreshPause()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
|
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
|
||||||
if forceRefreshTask != nil {
|
if forceRefreshTask != nil {
|
||||||
|
|
@ -328,9 +300,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
|
NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
|
||||||
forceRefreshTask?.cancel()
|
forceRefreshTask?.cancel()
|
||||||
forceRefreshTask = nil
|
forceRefreshTask = nil
|
||||||
forceRefreshStartedAt = nil
|
|
||||||
forceRefreshGeneration &+= 1
|
forceRefreshGeneration &+= 1
|
||||||
cancelLiveQuotaRefreshTasks()
|
|
||||||
store.resetLoadingState()
|
store.resetLoadingState()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -341,16 +311,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
forceRefreshTask = nil
|
forceRefreshTask = nil
|
||||||
forceRefreshStartedAt = nil
|
forceRefreshStartedAt = nil
|
||||||
forceRefreshGeneration &+= 1
|
forceRefreshGeneration &+= 1
|
||||||
cancelLiveQuotaRefreshTasks()
|
|
||||||
if let pausedUntil = forceRefreshBackoff.recordStall(now: now) {
|
|
||||||
store.pauseAutomaticRefresh(
|
|
||||||
until: pausedUntil,
|
|
||||||
consecutiveStalls: forceRefreshBackoff.consecutiveStalls
|
|
||||||
)
|
|
||||||
NSLog("CodeBurn: force refresh paused after %d consecutive stalls until %@",
|
|
||||||
forceRefreshBackoff.consecutiveStalls,
|
|
||||||
pausedUntil as NSDate)
|
|
||||||
}
|
|
||||||
store.resetLoadingState()
|
store.resetLoadingState()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -361,7 +321,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
|
private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
|
||||||
if statusPayloadRefreshTask != nil {
|
if statusPayloadRefreshTask != nil {
|
||||||
guard let started = statusPayloadRefreshStartedAt else {
|
guard let started = statusPayloadRefreshStartedAt else {
|
||||||
NSLog("CodeBurn: status refresh task had no start timestamp - clearing")
|
NSLog("CodeBurn: today status refresh task had no start timestamp - clearing")
|
||||||
statusPayloadRefreshTask?.cancel()
|
statusPayloadRefreshTask?.cancel()
|
||||||
statusPayloadRefreshTask = nil
|
statusPayloadRefreshTask = nil
|
||||||
statusPayloadRefreshGeneration &+= 1
|
statusPayloadRefreshGeneration &+= 1
|
||||||
|
|
@ -369,7 +329,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
}
|
}
|
||||||
let elapsed = now.timeIntervalSince(started)
|
let elapsed = now.timeIntervalSince(started)
|
||||||
guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false }
|
guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false }
|
||||||
NSLog("CodeBurn: status refresh stuck for %ds - cancelling", Int(elapsed))
|
NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed))
|
||||||
statusPayloadRefreshTask?.cancel()
|
statusPayloadRefreshTask?.cancel()
|
||||||
statusPayloadRefreshTask = nil
|
statusPayloadRefreshTask = nil
|
||||||
statusPayloadRefreshStartedAt = nil
|
statusPayloadRefreshStartedAt = nil
|
||||||
|
|
@ -406,7 +366,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
|
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
_ = clearStaleForceRefreshIfNeeded(now: now)
|
_ = clearStaleForceRefreshIfNeeded(now: now)
|
||||||
guard !isForceRefreshPaused(now: now) else { return }
|
|
||||||
if forceRefreshTask != nil {
|
if forceRefreshTask != nil {
|
||||||
refreshStatusPayloadIfNeeded(reason: "blocked force refresh")
|
refreshStatusPayloadIfNeeded(reason: "blocked force refresh")
|
||||||
}
|
}
|
||||||
|
|
@ -427,8 +386,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
_ = await quotas
|
_ = await quotas
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.forceRefreshGeneration == generation else { return }
|
guard let self, self.forceRefreshGeneration == generation else { return }
|
||||||
self.forceRefreshBackoff.recordSuccess()
|
|
||||||
self.store.clearRefreshPause()
|
|
||||||
self.forceRefreshTask = nil
|
self.forceRefreshTask = nil
|
||||||
self.forceRefreshStartedAt = nil
|
self.forceRefreshStartedAt = nil
|
||||||
self.lastRefreshTime = Date()
|
self.lastRefreshTime = Date()
|
||||||
|
|
@ -514,11 +471,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
let task = Task { [store] in
|
let task = Task { [store] in
|
||||||
await store.refreshSubscriptionReportingSuccess()
|
await store.refreshSubscriptionReportingSuccess()
|
||||||
}
|
}
|
||||||
claudeQuotaRefreshGeneration &+= 1
|
|
||||||
let generation = claudeQuotaRefreshGeneration
|
|
||||||
claudeQuotaRefreshTask = task
|
claudeQuotaRefreshTask = task
|
||||||
let result = await task.value
|
let result = await task.value
|
||||||
if claudeQuotaRefreshGeneration == generation {
|
if claudeQuotaRefreshTask != nil {
|
||||||
claudeQuotaRefreshTask = nil
|
claudeQuotaRefreshTask = nil
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
@ -531,11 +486,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
let task = Task { [store] in
|
let task = Task { [store] in
|
||||||
await store.refreshCodexReportingSuccess()
|
await store.refreshCodexReportingSuccess()
|
||||||
}
|
}
|
||||||
codexQuotaRefreshGeneration &+= 1
|
|
||||||
let generation = codexQuotaRefreshGeneration
|
|
||||||
codexQuotaRefreshTask = task
|
codexQuotaRefreshTask = task
|
||||||
let result = await task.value
|
let result = await task.value
|
||||||
if codexQuotaRefreshGeneration == generation {
|
if codexQuotaRefreshTask != nil {
|
||||||
codexQuotaRefreshTask = nil
|
codexQuotaRefreshTask = nil
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
@ -573,12 +526,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
|
private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
|
||||||
let now = Date()
|
refreshLoopHeartbeatAt = Date()
|
||||||
refreshLoopHeartbeatAt = now
|
|
||||||
let hadForceRefreshInFlight = forceRefreshTask != nil
|
let hadForceRefreshInFlight = forceRefreshTask != nil
|
||||||
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded(now: now)
|
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
|
||||||
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded(now: now)
|
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
|
||||||
guard !isForceRefreshPaused(now: now) else { return }
|
|
||||||
let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
|
let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
|
||||||
let statusPayloadStale = store.needsStatusPayloadRefresh
|
let statusPayloadStale = store.needsStatusPayloadRefresh
|
||||||
let sinceLast = Date().timeIntervalSince(lastRefreshTime)
|
let sinceLast = Date().timeIntervalSince(lastRefreshTime)
|
||||||
|
|
@ -632,9 +583,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
statusPayloadRefreshGeneration &+= 1
|
statusPayloadRefreshGeneration &+= 1
|
||||||
pendingRefreshWork?.cancel()
|
pendingRefreshWork?.cancel()
|
||||||
pendingRefreshWork = nil
|
pendingRefreshWork = nil
|
||||||
cancelLiveQuotaRefreshTasks()
|
|
||||||
forceRefreshBackoff.retryNow(resetStallCount: false)
|
|
||||||
store.clearRefreshPause()
|
|
||||||
stopRefreshTimer()
|
stopRefreshTimer()
|
||||||
store.resetRefreshState(clearCache: true)
|
store.resetRefreshState(clearCache: true)
|
||||||
lastRefreshTime = .distantPast
|
lastRefreshTime = .distantPast
|
||||||
|
|
@ -653,8 +601,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||||
self.refreshStatusButton()
|
self.refreshStatusButton()
|
||||||
_ = await quotas
|
_ = await quotas
|
||||||
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
|
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
|
||||||
self.forceRefreshBackoff.recordSuccess()
|
|
||||||
self.store.clearRefreshPause()
|
|
||||||
self.manualRefreshTask = nil
|
self.manualRefreshTask = nil
|
||||||
if self.refreshTimer == nil {
|
if self.refreshTimer == nil {
|
||||||
self.startRefreshLoop()
|
self.startRefreshLoop()
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,21 @@ enum DataClientError: Error {
|
||||||
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
|
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
|
||||||
/// commands through `/bin/zsh -c` anymore.
|
/// commands through `/bin/zsh -c` anymore.
|
||||||
struct DataClient {
|
struct DataClient {
|
||||||
static func fetch(period: Period, provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
|
static func fetch(period: Period, day: String? = nil, days: Set<String> = [], provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
|
||||||
var subcommand = [
|
var subcommand = [
|
||||||
"status",
|
"status",
|
||||||
"--format", "menubar-json",
|
"--format", "menubar-json",
|
||||||
"--period", period.cliArg,
|
|
||||||
"--provider", provider.cliArg,
|
"--provider", provider.cliArg,
|
||||||
]
|
]
|
||||||
|
if days.count > 1 {
|
||||||
|
subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
|
||||||
|
} else if let day {
|
||||||
|
subcommand.append(contentsOf: ["--day", day])
|
||||||
|
} else if let d = days.first {
|
||||||
|
subcommand.append(contentsOf: ["--day", d])
|
||||||
|
} else {
|
||||||
|
subcommand.append(contentsOf: ["--period", period.cliArg])
|
||||||
|
}
|
||||||
if !includeOptimize {
|
if !includeOptimize {
|
||||||
subcommand.append("--no-optimize")
|
subcommand.append("--no-optimize")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ struct HeatmapSection: View {
|
||||||
} else {
|
} else {
|
||||||
PlanInsight(usage: store.subscription)
|
PlanInsight(usage: store.subscription)
|
||||||
}
|
}
|
||||||
case .trend: TrendInsight(days: store.payload.history.daily, period: store.selectedPeriod)
|
case .trend: TrendInsight(days: store.payload.history.daily, period: store.trendPeriod)
|
||||||
case .forecast: ForecastInsight(days: store.payload.history.daily)
|
case .forecast: ForecastInsight(days: store.payload.history.daily)
|
||||||
case .pulse: PulseInsight(payload: store.payload)
|
case .pulse: PulseInsight(payload: store.payload)
|
||||||
case .stats: StatsInsight(payload: store.payload)
|
case .stats: StatsInsight(payload: store.payload)
|
||||||
|
|
@ -1839,4 +1839,3 @@ private func relativeReset(_ date: Date) -> String {
|
||||||
let days = Int(ceil(hours / 24))
|
let days = Int(ceil(hours / 24))
|
||||||
return "in \(days)d"
|
return "in \(days)d"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ struct HeroSection: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if store.selectedPeriod == .today,
|
if !store.isDayMode,
|
||||||
|
store.selectedPeriod == .today,
|
||||||
store.dailyBudget > 0,
|
store.dailyBudget > 0,
|
||||||
let todayCost = store.todayPayload?.current.cost,
|
let todayCost = store.todayPayload?.current.cost,
|
||||||
todayCost >= store.dailyBudget {
|
todayCost >= store.dailyBudget {
|
||||||
|
|
@ -92,7 +93,7 @@ struct HeroSection: View {
|
||||||
|
|
||||||
private var caption: String {
|
private var caption: String {
|
||||||
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
|
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
|
||||||
if store.selectedPeriod == .today {
|
if !store.isDayMode && store.selectedPeriod == .today {
|
||||||
return "\(label) · \(todayDate)"
|
return "\(label) · \(todayDate)"
|
||||||
}
|
}
|
||||||
return label
|
return label
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,6 @@ struct MenuBarContent: View {
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
if let message = store.refreshPauseMessage {
|
|
||||||
RefreshPausedBanner(
|
|
||||||
message: message,
|
|
||||||
retry: { refreshNow() }
|
|
||||||
)
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
|
|
||||||
if showAgentTabs {
|
if showAgentTabs {
|
||||||
AgentTabStrip()
|
AgentTabStrip()
|
||||||
Divider()
|
Divider()
|
||||||
|
|
@ -32,7 +24,7 @@ struct MenuBarContent: View {
|
||||||
PeriodSegmentedControl()
|
PeriodSegmentedControl()
|
||||||
Divider().opacity(0.5)
|
Divider().opacity(0.5)
|
||||||
if isFilteredEmpty {
|
if isFilteredEmpty {
|
||||||
EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod)
|
EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel)
|
||||||
} else {
|
} else {
|
||||||
HeatmapSection()
|
HeatmapSection()
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
|
|
@ -57,22 +49,15 @@ struct MenuBarContent: View {
|
||||||
// error, etc.), surface a retry card instead of leaving the
|
// error, etc.), surface a retry card instead of leaving the
|
||||||
// user stuck on a perpetual "Loading..." spinner.
|
// user stuck on a perpetual "Loading..." spinner.
|
||||||
if !store.hasCachedData {
|
if !store.hasCachedData {
|
||||||
if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad {
|
if let err = store.lastError {
|
||||||
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
|
|
||||||
.transition(.opacity)
|
|
||||||
} else if let err = store.lastError {
|
|
||||||
FetchErrorOverlay(
|
FetchErrorOverlay(
|
||||||
error: err,
|
error: err,
|
||||||
periodLabel: store.selectedPeriod.rawValue,
|
periodLabel: store.selectionLabel,
|
||||||
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
|
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
|
||||||
)
|
)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
FetchErrorOverlay(
|
BurnLoadingOverlay(periodLabel: store.selectionLabel)
|
||||||
error: "The last refresh stopped before returning data. CodeBurn will keep retrying, or you can retry now.",
|
|
||||||
periodLabel: store.selectedPeriod.rawValue,
|
|
||||||
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,42 +105,16 @@ struct MenuBarContent: View {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct RefreshPausedBanner: View {
|
|
||||||
let message: String
|
|
||||||
let retry: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: "pause.circle.fill")
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundStyle(Theme.brandAccent)
|
|
||||||
Text(message)
|
|
||||||
.font(.system(size: 10.5, weight: .medium))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
Spacer(minLength: 6)
|
|
||||||
Button("Retry", action: retry)
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(Theme.brandAccent)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.secondary.opacity(0.08))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct EmptyProviderState: View {
|
private struct EmptyProviderState: View {
|
||||||
let provider: ProviderFilter
|
let provider: ProviderFilter
|
||||||
let period: Period
|
let periodLabel: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "tray")
|
Image(systemName: "tray")
|
||||||
.font(.system(size: 26))
|
.font(.system(size: 26))
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
Text("No \(provider.rawValue) data for \(periodPhrase)")
|
Text("No \(provider.rawValue) data for \(periodLabel)")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
@ -164,15 +123,6 @@ private struct EmptyProviderState: View {
|
||||||
.padding(.vertical, 60)
|
.padding(.vertical, 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var periodPhrase: String {
|
|
||||||
switch period {
|
|
||||||
case .today: "today"
|
|
||||||
case .sevenDays: "the last 7 days"
|
|
||||||
case .thirtyDays: "the last 30 days"
|
|
||||||
case .month: "this month"
|
|
||||||
case .all: "the last 6 months"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shown when a fetch failed and the cache is still empty for this key. The
|
/// Shown when a fetch failed and the cache is still empty for this key. The
|
||||||
|
|
@ -628,7 +578,11 @@ struct FooterBar: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshNow() {
|
private func refreshNow() {
|
||||||
MenuBarContent.refreshNow(store: store)
|
if let delegate = NSApp.delegate as? AppDelegate {
|
||||||
|
delegate.refreshSubscriptionNow()
|
||||||
|
} else {
|
||||||
|
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ExportFormat {
|
private enum ExportFormat {
|
||||||
|
|
@ -694,17 +648,3 @@ struct FooterBar: View {
|
||||||
CLICurrencyConfig.persist(code: code)
|
CLICurrencyConfig.persist(code: code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension MenuBarContent {
|
|
||||||
static func refreshNow(store: AppStore) {
|
|
||||||
if let delegate = NSApp.delegate as? AppDelegate {
|
|
||||||
delegate.refreshSubscriptionNow()
|
|
||||||
} else {
|
|
||||||
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshNow() {
|
|
||||||
Self.refreshNow(store: store)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@ import SwiftUI
|
||||||
|
|
||||||
struct PeriodSegmentedControl: View {
|
struct PeriodSegmentedControl: View {
|
||||||
@Environment(AppStore.self) private var store
|
@Environment(AppStore.self) private var store
|
||||||
|
@State private var showingCalendar = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 1) {
|
HStack(spacing: 1) {
|
||||||
ForEach(Period.allCases) { period in
|
ForEach(Period.allCases) { period in
|
||||||
|
let isActive = !store.isDayMode && store.selectedPeriod == period
|
||||||
Button {
|
Button {
|
||||||
store.switchTo(period: period)
|
store.switchTo(period: period)
|
||||||
} label: {
|
} label: {
|
||||||
Text(period.rawValue)
|
Text(period.rawValue)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(store.selectedPeriod == period ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
|
.foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|
@ -19,10 +21,31 @@ struct PeriodSegmentedControl: View {
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 5)
|
RoundedRectangle(cornerRadius: 5)
|
||||||
.fill(store.selectedPeriod == period ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
|
.fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
|
||||||
.shadow(color: .black.opacity(store.selectedPeriod == period ? 0.06 : 0), radius: 1, y: 0.5)
|
.shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingCalendar.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(store.isDayMode ? Theme.brandAccent : .secondary)
|
||||||
|
.frame(width: 28)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.fill(store.isDayMode ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
|
||||||
|
.shadow(color: .black.opacity(store.isDayMode ? 0.06 : 0), radius: 1, y: 0.5)
|
||||||
|
)
|
||||||
|
.popover(isPresented: $showingCalendar, arrowEdge: .bottom) {
|
||||||
|
CalendarPopover(isPresented: $showingCalendar)
|
||||||
|
.environment(store)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(2)
|
.padding(2)
|
||||||
.background(
|
.background(
|
||||||
|
|
@ -34,3 +57,248 @@ struct PeriodSegmentedControl: View {
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct CalendarPopover: View {
|
||||||
|
@Environment(AppStore.self) private var store
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@State private var displayMonth = Date()
|
||||||
|
@State private var pending: Set<String> = []
|
||||||
|
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
private let weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
||||||
|
private let cellSize: CGFloat = 30
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Button { shiftMonth(-1) } label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(monthYearLabel)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button { shiftMonth(1) } label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(canGoForward ? .secondary : Color.secondary.opacity(0.3))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canGoForward)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(weekdays, id: \.self) { day in
|
||||||
|
Text(day)
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(width: cellSize, height: 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.fixed(cellSize), spacing: 0), count: 7), spacing: 2) {
|
||||||
|
ForEach(dayCells, id: \.id) { cell in
|
||||||
|
DayCellView(
|
||||||
|
cell: cell,
|
||||||
|
isSelected: pending.contains(cell.dateString),
|
||||||
|
isToday: cell.dateString == todayString,
|
||||||
|
isFuture: cell.dateString > todayString
|
||||||
|
) {
|
||||||
|
toggleDay(cell.dateString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if !pending.isEmpty {
|
||||||
|
Button("Clear") {
|
||||||
|
pending = []
|
||||||
|
}
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(selectionSummary)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if !pending.isEmpty {
|
||||||
|
store.switchTo(days: pending)
|
||||||
|
} else {
|
||||||
|
store.switchTo(period: store.selectedPeriod)
|
||||||
|
}
|
||||||
|
isPresented = false
|
||||||
|
} label: {
|
||||||
|
Text("Done")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.fill(pending.isEmpty ? Color.secondary.opacity(0.3) : Theme.brandAccent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
.frame(width: cellSize * 7 + 24)
|
||||||
|
.onAppear {
|
||||||
|
pending = store.selectedDays
|
||||||
|
if let first = store.selectedDays.sorted().first,
|
||||||
|
let date = AppStore.dayFormatter.date(from: first) {
|
||||||
|
displayMonth = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var todayString: String {
|
||||||
|
AppStore.dayString(from: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthYearLabel: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "MMMM yyyy"
|
||||||
|
return f.string(from: displayMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canGoForward: Bool {
|
||||||
|
let nextMonth = calendar.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth
|
||||||
|
return calendar.startOfDay(for: nextMonth) <= calendar.startOfDay(for: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectionSummary: String {
|
||||||
|
if pending.isEmpty { return "Pick dates" }
|
||||||
|
if pending.count == 1 { return "1 day" }
|
||||||
|
return "\(pending.count) days"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shiftMonth(_ delta: Int) {
|
||||||
|
if let next = calendar.date(byAdding: .month, value: delta, to: displayMonth) {
|
||||||
|
displayMonth = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleDay(_ day: String) {
|
||||||
|
guard day <= todayString else { return }
|
||||||
|
if pending.contains(day) {
|
||||||
|
pending.remove(day)
|
||||||
|
} else {
|
||||||
|
pending.insert(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dayCells: [DayCell] {
|
||||||
|
let comps = calendar.dateComponents([.year, .month], from: displayMonth)
|
||||||
|
guard let firstOfMonth = calendar.date(from: comps),
|
||||||
|
let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { return [] }
|
||||||
|
|
||||||
|
var weekdayOfFirst = calendar.component(.weekday, from: firstOfMonth) - 2
|
||||||
|
if weekdayOfFirst < 0 { weekdayOfFirst += 7 }
|
||||||
|
|
||||||
|
var cells: [DayCell] = []
|
||||||
|
|
||||||
|
for offset in stride(from: -weekdayOfFirst, to: 0, by: 1) {
|
||||||
|
if let date = calendar.date(byAdding: .day, value: offset, to: firstOfMonth) {
|
||||||
|
let d = calendar.component(.day, from: date)
|
||||||
|
cells.append(DayCell(id: "prev-\(offset)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for day in range {
|
||||||
|
if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) {
|
||||||
|
cells.append(DayCell(id: "cur-\(day)", day: day, dateString: AppStore.dayString(from: date), isCurrentMonth: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainder = (7 - cells.count % 7) % 7
|
||||||
|
if let lastOfMonth = calendar.date(byAdding: .day, value: range.count - 1, to: firstOfMonth) {
|
||||||
|
for i in 1...max(remainder, 1) {
|
||||||
|
if let date = calendar.date(byAdding: .day, value: i, to: lastOfMonth) {
|
||||||
|
let d = calendar.component(.day, from: date)
|
||||||
|
cells.append(DayCell(id: "next-\(i)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cells.count % 7 != 0 {
|
||||||
|
cells = Array(cells.prefix(cells.count - cells.count % 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DayCell: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let day: Int
|
||||||
|
let dateString: String
|
||||||
|
let isCurrentMonth: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DayCellView: View {
|
||||||
|
let cell: DayCell
|
||||||
|
let isSelected: Bool
|
||||||
|
let isToday: Bool
|
||||||
|
let isFuture: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text("\(cell.day)")
|
||||||
|
.font(.system(size: 11, weight: isToday ? .bold : .regular))
|
||||||
|
.foregroundStyle(foregroundColor)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(isToday && !isSelected ? Theme.brandAccent.opacity(0.5) : .clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isFuture)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foregroundColor: Color {
|
||||||
|
if isFuture { return Color.secondary.opacity(0.25) }
|
||||||
|
if isSelected { return .white }
|
||||||
|
if !cell.isCurrentMonth { return Color.secondary.opacity(0.4) }
|
||||||
|
if isToday { return Theme.brandAccent }
|
||||||
|
return .primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
if isSelected { return Theme.brandAccent }
|
||||||
|
return .clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,49 @@ function parseLocalDate(s: string): Date {
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function endOfLocalDay(date: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
END_OF_DAY_HOURS,
|
||||||
|
END_OF_DAY_MINUTES,
|
||||||
|
END_OF_DAY_SECONDS,
|
||||||
|
END_OF_DAY_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayRangeForDate(date: Date): DateRange {
|
||||||
|
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
return { start, end: endOfLocalDay(start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDayRangeLabel(day: string): string {
|
||||||
|
return `Day (${day})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftDay(day: string, delta: number): string {
|
||||||
|
const date = parseLocalDate(day)
|
||||||
|
return toDateString(new Date(date.getFullYear(), date.getMonth(), date.getDate() + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDayFlag(day: string | undefined): { day: string; range: DateRange; label: string } | null {
|
||||||
|
if (day === undefined) return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
let date: Date
|
||||||
|
if (day === 'today') {
|
||||||
|
date = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
} else if (day === 'yesterday') {
|
||||||
|
date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
|
} else {
|
||||||
|
date = parseLocalDate(day)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedDay = toDateString(date)
|
||||||
|
return { day: resolvedDay, range: dayRangeForDate(date), label: formatDayRangeLabel(resolvedDay) }
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
|
export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
|
||||||
if (from === undefined && to === undefined) return null
|
if (from === undefined && to === undefined) return null
|
||||||
|
|
||||||
|
|
@ -72,15 +115,7 @@ export function parseDateRangeFlags(from: string | undefined, to: string | undef
|
||||||
: new Date(now.getTime() - ALL_TIME_FALLBACK_MS)
|
: new Date(now.getTime() - ALL_TIME_FALLBACK_MS)
|
||||||
|
|
||||||
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const end = new Date(
|
const end = endOfLocalDay(endDate)
|
||||||
endDate.getFullYear(),
|
|
||||||
endDate.getMonth(),
|
|
||||||
endDate.getDate(),
|
|
||||||
END_OF_DAY_HOURS,
|
|
||||||
END_OF_DAY_MINUTES,
|
|
||||||
END_OF_DAY_SECONDS,
|
|
||||||
END_OF_DAY_MS,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (start > end) {
|
if (start > end) {
|
||||||
throw new Error(`--from must not be after --to (got ${from} > ${to})`)
|
throw new Error(`--from must not be after --to (got ${from} > ${to})`)
|
||||||
|
|
@ -113,12 +148,11 @@ export function getDateRange(period: string): { range: DateRange; label: string
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case 'today': {
|
case 'today': {
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
return { range: { start, end }, label: `Today (${toDateString(start)})` }
|
return { range: dayRangeForDate(start), label: `Today (${toDateString(start)})` }
|
||||||
}
|
}
|
||||||
case 'yesterday': {
|
case 'yesterday': {
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, END_OF_DAY_HOURS, END_OF_DAY_MINUTES, END_OF_DAY_SECONDS, END_OF_DAY_MS)
|
return { range: dayRangeForDate(start), label: `Yesterday (${toDateString(start)})` }
|
||||||
return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` }
|
|
||||||
}
|
}
|
||||||
case 'week': {
|
case 'week': {
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
||||||
|
|
@ -145,6 +179,22 @@ export function getDateRange(period: string): { range: DateRange; label: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseDaysFlag(days: string | undefined): { days: Set<string>; range: DateRange; label: string } | null {
|
||||||
|
if (days === undefined) return null
|
||||||
|
const list = days.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (list.length === 0) return null
|
||||||
|
const dates = list.map(parseLocalDate)
|
||||||
|
const strings = dates.map(toDateString)
|
||||||
|
const sorted = [...strings].sort()
|
||||||
|
const startDate = parseLocalDate(sorted[0]!)
|
||||||
|
const endDate = parseLocalDate(sorted[sorted.length - 1]!)
|
||||||
|
return {
|
||||||
|
days: new Set(sorted),
|
||||||
|
range: { start: startDate, end: endOfLocalDay(endDate) },
|
||||||
|
label: sorted.length === 1 ? sorted[0]! : `${sorted.length} days (${sorted[0]} .. ${sorted[sorted.length - 1]})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string {
|
export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string {
|
||||||
return `${from ?? 'all'} to ${to ?? 'today'}`
|
return `${from ?? 'all'} to ${to ?? 'today'}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { dateKey } from './day-aggregator.js'
|
||||||
import { CompareView } from './compare.js'
|
import { CompareView } from './compare.js'
|
||||||
import { getPlanUsages, type PlanUsage } from './plan-usage.js'
|
import { getPlanUsages, type PlanUsage } from './plan-usage.js'
|
||||||
import { planDisplayName } from './plans.js'
|
import { planDisplayName } from './plans.js'
|
||||||
import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js'
|
import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js'
|
||||||
import { patchStdoutForWindows } from './ink-win.js'
|
import { patchStdoutForWindows } from './ink-win.js'
|
||||||
|
|
||||||
type View = 'dashboard' | 'optimize' | 'compare'
|
type View = 'dashboard' | 'optimize' | 'compare'
|
||||||
|
|
@ -102,6 +102,10 @@ function getPeriodRange(period: Period): { start: Date; end: Date } {
|
||||||
return getDateRange(period).range
|
return getDateRange(period).range
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDayRange(day: string): DateRange {
|
||||||
|
return parseDayFlag(day)!.range
|
||||||
|
}
|
||||||
|
|
||||||
function isHeavyPeriod(period: Period): boolean {
|
function isHeavyPeriod(period: Period): boolean {
|
||||||
return HEAVY_PERIODS.has(period)
|
return HEAVY_PERIODS.has(period)
|
||||||
}
|
}
|
||||||
|
|
@ -648,14 +652,16 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable, customRange }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean; customRange?: boolean }) {
|
function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable, customRange, dayMode }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean; customRange?: boolean; dayMode?: boolean }) {
|
||||||
const isOptimize = view === 'optimize'
|
const isOptimize = view === 'optimize'
|
||||||
return (
|
return (
|
||||||
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
|
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
|
||||||
<Text>
|
<Text>
|
||||||
{isOptimize
|
{isOptimize
|
||||||
? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text><Text color={ORANGE} bold>j</Text><Text dimColor>/</Text><Text color={ORANGE} bold>k</Text><Text dimColor> scroll </Text></>
|
? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text><Text color={ORANGE} bold>j</Text><Text dimColor>/</Text><Text color={ORANGE} bold>k</Text><Text dimColor> scroll </Text></>
|
||||||
: !customRange
|
: dayMode
|
||||||
|
? <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> day </Text></>
|
||||||
|
: !customRange
|
||||||
? <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></>
|
? <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></>
|
||||||
: null}
|
: null}
|
||||||
<Text color={ORANGE} bold>q</Text><Text dimColor> quit</Text>
|
<Text color={ORANGE} bold>q</Text><Text dimColor> quit</Text>
|
||||||
|
|
@ -668,6 +674,11 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable,
|
||||||
<Text color={ORANGE} bold>5</Text><Text dimColor> 6 months</Text>
|
<Text color={ORANGE} bold>5</Text><Text dimColor> 6 months</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!customRange && !isOptimize && (
|
||||||
|
<>
|
||||||
|
<Text dimColor> </Text><Text color={ORANGE} bold>d</Text><Text dimColor>{dayMode ? ' exit day' : ' yesterday'}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!isOptimize && optimizeAvailable && (
|
{!isOptimize && optimizeAvailable && (
|
||||||
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text>{findingCount != null && findingCount > 0 ? <Text color="#F55B5B"> ({findingCount})</Text> : null}</>
|
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text>{findingCount != null && findingCount > 0 ? <Text color="#F55B5B"> ({findingCount})</Text> : null}</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -685,15 +696,16 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[] }) {
|
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) {
|
||||||
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
|
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
|
||||||
const isCursor = activeProvider === 'cursor'
|
const isCursor = activeProvider === 'cursor'
|
||||||
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
|
const activeLabel = label ?? PERIOD_LABELS[period]
|
||||||
|
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {activeLabel}.</Text></Panel>
|
||||||
const pw = wide ? halfWidth : dashWidth
|
const pw = wide ? halfWidth : dashWidth
|
||||||
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
|
const days = dayMode ? 1 : period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={dashWidth}>
|
<Box flexDirection="column" width={dashWidth}>
|
||||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsages={planUsages} />
|
<Overview projects={projects} label={activeLabel} width={dashWidth} planUsages={planUsages} />
|
||||||
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
|
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
|
||||||
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
||||||
{isCursor ? (
|
{isCursor ? (
|
||||||
|
|
@ -705,7 +717,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: {
|
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: {
|
||||||
initialProjects: ProjectSummary[]
|
initialProjects: ProjectSummary[]
|
||||||
initialPeriod: Period
|
initialPeriod: Period
|
||||||
initialProvider: string
|
initialProvider: string
|
||||||
|
|
@ -715,6 +727,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
excludeFilter?: string[]
|
excludeFilter?: string[]
|
||||||
customRange?: DateRange | null
|
customRange?: DateRange | null
|
||||||
customRangeLabel?: string
|
customRangeLabel?: string
|
||||||
|
initialDay?: string
|
||||||
}) {
|
}) {
|
||||||
const { exit } = useApp()
|
const { exit } = useApp()
|
||||||
const [period, setPeriod] = useState<Period>(initialPeriod)
|
const [period, setPeriod] = useState<Period>(initialPeriod)
|
||||||
|
|
@ -727,11 +740,13 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
const [optimizeLoading, setOptimizeLoading] = useState(false)
|
const [optimizeLoading, setOptimizeLoading] = useState(false)
|
||||||
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
||||||
const [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? [])
|
const [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? [])
|
||||||
|
const [dayDate, setDayDate] = useState<string | null>(initialDay ?? null)
|
||||||
// Cursor for the OptimizeView's findings window. Reset whenever the user
|
// Cursor for the OptimizeView's findings window. Reset whenever the user
|
||||||
// leaves the optimize view OR the underlying findings change so a long
|
// leaves the optimize view OR the underlying findings change so a long
|
||||||
// findings list never strands the user past the new array length.
|
// findings list never strands the user past the new array length.
|
||||||
const [findingsCursor, setFindingsCursor] = useState(0)
|
const [findingsCursor, setFindingsCursor] = useState(0)
|
||||||
const isCustomRange = customRange != null
|
const isDayMode = dayDate != null
|
||||||
|
const isCustomRange = customRange != null && !isDayMode
|
||||||
const { columns } = useWindowSize()
|
const { columns } = useWindowSize()
|
||||||
const { dashWidth } = getLayout(columns)
|
const { dashWidth } = getLayout(columns)
|
||||||
const multipleProviders = detectedProviders.length > 1
|
const multipleProviders = detectedProviders.length > 1
|
||||||
|
|
@ -743,8 +758,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const reloadGenerationRef = useRef(0)
|
const reloadGenerationRef = useRef(0)
|
||||||
const reloadInFlightRef = useRef(false)
|
const reloadInFlightRef = useRef(false)
|
||||||
const currentReloadRef = useRef<{ period: Period; provider: string } | null>(null)
|
const currentReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
|
||||||
const pendingReloadRef = useRef<{ period: Period; provider: string } | null>(null)
|
const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null)
|
||||||
const findingCount = optimizeResult?.findings.length ?? 0
|
const findingCount = optimizeResult?.findings.length ?? 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -773,31 +788,31 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [projects])
|
}, [projects])
|
||||||
|
|
||||||
const reloadData = useCallback(async (p: Period, prov: string) => {
|
const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null) => {
|
||||||
if (reloadInFlightRef.current) {
|
if (reloadInFlightRef.current) {
|
||||||
const current = currentReloadRef.current
|
const current = currentReloadRef.current
|
||||||
if (current?.period === p && current.provider === prov) {
|
if (current?.period === p && current.provider === prov && current.day === day) {
|
||||||
pendingReloadRef.current = null
|
pendingReloadRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadGenerationRef.current++
|
reloadGenerationRef.current++
|
||||||
pendingReloadRef.current = { period: p, provider: prov }
|
pendingReloadRef.current = { period: p, provider: prov, day }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadInFlightRef.current = true
|
reloadInFlightRef.current = true
|
||||||
currentReloadRef.current = { period: p, provider: prov }
|
currentReloadRef.current = { period: p, provider: prov, day }
|
||||||
const generation = ++reloadGenerationRef.current
|
const generation = ++reloadGenerationRef.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setOptimizeLoading(false)
|
setOptimizeLoading(false)
|
||||||
setOptimizeResult(null)
|
setOptimizeResult(null)
|
||||||
try {
|
try {
|
||||||
if (isHeavyPeriod(p)) {
|
if (!day && isHeavyPeriod(p)) {
|
||||||
setProjects([])
|
setProjects([])
|
||||||
setProjectBudgets(new Map())
|
setProjectBudgets(new Map())
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (reloadGenerationRef.current !== generation) return
|
if (reloadGenerationRef.current !== generation) return
|
||||||
}
|
}
|
||||||
const range = getPeriodRange(p)
|
const range = day ? getDayRange(day) : getPeriodRange(p)
|
||||||
const data = await parseAllSessions(range, prov)
|
const data = await parseAllSessions(range, prov)
|
||||||
if (reloadGenerationRef.current !== generation) return
|
if (reloadGenerationRef.current !== generation) return
|
||||||
|
|
||||||
|
|
@ -819,11 +834,15 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
const pending = pendingReloadRef.current
|
const pending = pendingReloadRef.current
|
||||||
pendingReloadRef.current = null
|
pendingReloadRef.current = null
|
||||||
if (pending) {
|
if (pending) {
|
||||||
void reloadData(pending.period, pending.provider)
|
void reloadData(pending.period, pending.provider, pending.day)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [projectFilter, excludeFilter])
|
}, [projectFilter, excludeFilter])
|
||||||
|
|
||||||
|
const currentRange = useCallback(() => {
|
||||||
|
return dayDate ? getDayRange(dayDate) : getPeriodRange(period)
|
||||||
|
}, [dayDate, period])
|
||||||
|
|
||||||
const loadOptimizeResult = useCallback(async () => {
|
const loadOptimizeResult = useCallback(async () => {
|
||||||
if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return
|
if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return
|
||||||
setView('optimize')
|
setView('optimize')
|
||||||
|
|
@ -833,44 +852,63 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
const generation = reloadGenerationRef.current
|
const generation = reloadGenerationRef.current
|
||||||
setOptimizeLoading(true)
|
setOptimizeLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await scanAndDetect(projects, getPeriodRange(period))
|
const result = await scanAndDetect(projects, currentRange())
|
||||||
if (reloadGenerationRef.current === generation) setOptimizeResult(result)
|
if (reloadGenerationRef.current === generation) setOptimizeResult(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
if (reloadGenerationRef.current === generation) setOptimizeLoading(false)
|
if (reloadGenerationRef.current === generation) setOptimizeLoading(false)
|
||||||
}
|
}
|
||||||
}, [optimizeAvailable, projects, period, optimizeLoading, optimizeResult])
|
}, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!refreshSeconds || refreshSeconds <= 0) return
|
if (!refreshSeconds || refreshSeconds <= 0) return
|
||||||
if (isHeavyPeriod(period)) return
|
if (!dayDate && isHeavyPeriod(period)) return
|
||||||
const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000)
|
const id = setInterval(() => { reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [refreshSeconds, period, activeProvider, reloadData])
|
}, [refreshSeconds, period, activeProvider, dayDate, reloadData])
|
||||||
|
|
||||||
const switchPeriod = useCallback((np: Period) => {
|
const switchPeriod = useCallback((np: Period) => {
|
||||||
if (np === period) return
|
if (np === period && !dayDate) return
|
||||||
// Clear projects + flip loading synchronously so the dashboard never
|
// Clear projects + flip loading synchronously so the dashboard never
|
||||||
// renders the new period label over the old period's numbers between
|
// renders the new period label over the old period's numbers between
|
||||||
// setPeriod() and the reloadData() promise resolving. Without this,
|
// setPeriod() and the reloadData() promise resolving. Without this,
|
||||||
// there's a frame-to-hundreds-of-ms window where users saw wrong
|
// there's a frame-to-hundreds-of-ms window where users saw wrong
|
||||||
// figures captioned with the new period.
|
// figures captioned with the new period.
|
||||||
setPeriod(np)
|
setPeriod(np)
|
||||||
|
setDayDate(null)
|
||||||
setProjects([])
|
setProjects([])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600)
|
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider, null) }, 600)
|
||||||
}, [period, activeProvider, reloadData])
|
}, [period, activeProvider, dayDate, reloadData])
|
||||||
|
|
||||||
const switchPeriodImmediate = useCallback(async (np: Period) => {
|
const switchPeriodImmediate = useCallback(async (np: Period) => {
|
||||||
if (np === period) return
|
if (np === period && !dayDate) return
|
||||||
setPeriod(np)
|
setPeriod(np)
|
||||||
|
setDayDate(null)
|
||||||
setProjects([])
|
setProjects([])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
await reloadData(np, activeProvider)
|
await reloadData(np, activeProvider, null)
|
||||||
}, [period, activeProvider, reloadData])
|
}, [period, activeProvider, dayDate, reloadData])
|
||||||
|
|
||||||
|
const switchDay = useCallback(async (nextDay: string) => {
|
||||||
|
const today = parseDayFlag('today')!.day
|
||||||
|
const clampedDay = nextDay > today ? today : nextDay
|
||||||
|
if (clampedDay === dayDate) return
|
||||||
|
setDayDate(clampedDay)
|
||||||
|
setProjects([])
|
||||||
|
setLoading(true)
|
||||||
|
setView('dashboard')
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
await reloadData(period, activeProvider, clampedDay)
|
||||||
|
}, [period, activeProvider, dayDate, reloadData])
|
||||||
|
|
||||||
|
const enterYesterday = useCallback(async () => {
|
||||||
|
const yesterday = parseDayFlag('yesterday')!.day
|
||||||
|
await switchDay(yesterday)
|
||||||
|
}, [switchDay])
|
||||||
|
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (input === 'q') { exit(); return }
|
if (input === 'q') { exit(); return }
|
||||||
|
|
@ -881,6 +919,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE)
|
const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE)
|
||||||
if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return }
|
if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return }
|
||||||
if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return }
|
if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return }
|
if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return }
|
||||||
if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return }
|
if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return }
|
||||||
|
|
@ -888,18 +927,40 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length]
|
const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length]
|
||||||
setActiveProvider(next); setView('dashboard')
|
setActiveProvider(next); setView('dashboard')
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
reloadData(period, next); return
|
reloadData(period, next, dayDate); return
|
||||||
}
|
}
|
||||||
// Period switches reload the underlying data. Disable them while the
|
// Period switches reload the underlying data. Disable them while the
|
||||||
// compare view is mounted; the compare view re-aggregates from
|
// compare view is mounted; the compare view re-aggregates from
|
||||||
// `projects` and would visibly change underneath the user without any
|
// `projects` and would visibly change underneath the user without any
|
||||||
// affordance back to the dashboard. Press `b` or Esc to return first.
|
// affordance back to the dashboard. Press `b` or Esc to return first.
|
||||||
if (view === 'compare') return
|
if (view === 'compare') return
|
||||||
|
if (!customRange && input === 'd') {
|
||||||
|
if (dayDate) {
|
||||||
|
setDayDate(null)
|
||||||
|
setProjects([])
|
||||||
|
setLoading(true)
|
||||||
|
void reloadData(period, activeProvider, null)
|
||||||
|
} else {
|
||||||
|
void enterYesterday()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
// Also disable while a custom --from/--to range is in effect. Switching
|
// Also disable while a custom --from/--to range is in effect. Switching
|
||||||
// period would silently abandon the user's explicit range and reload
|
// period would silently abandon the user's explicit range and reload
|
||||||
// standard period data; the period tab strip is hidden in this mode so
|
// standard period data; the period tab strip is hidden in this mode so
|
||||||
// users have no expectation that 1-5 should do anything.
|
// users have no expectation that 1-5 should do anything.
|
||||||
if (isCustomRange) return
|
if (isCustomRange) return
|
||||||
|
if (dayDate) {
|
||||||
|
if (key.leftArrow) { void switchDay(shiftDay(dayDate, -1)); return }
|
||||||
|
if (key.rightArrow || key.tab) { void switchDay(shiftDay(dayDate, 1)); return }
|
||||||
|
if (key.escape || input === 'b') {
|
||||||
|
setDayDate(null)
|
||||||
|
setProjects([])
|
||||||
|
setLoading(true)
|
||||||
|
void reloadData(period, activeProvider, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
const idx = PERIODS.indexOf(period)
|
const idx = PERIODS.indexOf(period)
|
||||||
if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]!)
|
if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]!)
|
||||||
else if (key.rightArrow || key.tab) switchPeriod(PERIODS[(idx + 1) % PERIODS.length]!)
|
else if (key.rightArrow || key.tab) switchPeriod(PERIODS[(idx + 1) % PERIODS.length]!)
|
||||||
|
|
@ -910,12 +971,13 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
else if (input === '5') switchPeriodImmediate('all')
|
else if (input === '5') switchPeriodImmediate('all')
|
||||||
})
|
})
|
||||||
|
|
||||||
const headerLabel = customRangeLabel ?? PERIOD_LABELS[period]
|
const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period]
|
||||||
|
|
||||||
if (loading || optimizeLoading) {
|
if (loading || optimizeLoading) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={dashWidth}>
|
<Box flexDirection="column" width={dashWidth}>
|
||||||
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />}
|
{!isCustomRange && !isDayMode && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />}
|
||||||
|
{isDayMode && <DayBanner label={headerLabel} width={dashWidth} />}
|
||||||
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
|
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
|
||||||
{view === 'compare'
|
{view === 'compare'
|
||||||
? <Box flexDirection="column" paddingX={2} paddingY={1}>
|
? <Box flexDirection="column" paddingX={2} paddingY={1}>
|
||||||
|
|
@ -928,21 +990,30 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
||||||
: view === 'optimize'
|
: view === 'optimize'
|
||||||
? <Panel title="CodeBurn Optimize" color={ORANGE} width={dashWidth}><Text dimColor>Scanning {headerLabel}...</Text></Panel>
|
? <Panel title="CodeBurn Optimize" color={ORANGE} width={dashWidth}><Text dimColor>Scanning {headerLabel}...</Text></Panel>
|
||||||
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
|
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
|
||||||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} />}
|
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} dayMode={isDayMode} />}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={dashWidth}>
|
<Box flexDirection="column" width={dashWidth}>
|
||||||
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders && view !== 'compare'} />}
|
{!isCustomRange && !isDayMode && <PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders && view !== 'compare'} />}
|
||||||
|
{isDayMode && <DayBanner label={headerLabel} width={dashWidth} />}
|
||||||
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
|
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
|
||||||
{view === 'compare'
|
{view === 'compare'
|
||||||
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
|
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
|
||||||
: view === 'optimize' && optimizeResult
|
: view === 'optimize' && optimizeResult
|
||||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
|
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
|
||||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} />}
|
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} />}
|
||||||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} />}
|
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} dayMode={isDayMode} />}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayBanner({ label, width }: { label: string; width: number }) {
|
||||||
|
return (
|
||||||
|
<Box width={width} paddingX={1} marginBottom={1}>
|
||||||
|
<Text color={ORANGE} bold>{label}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -956,31 +1027,33 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StaticDashboard({ projects, period, activeProvider, planUsages }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[] }) {
|
function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) {
|
||||||
const { columns } = useWindowSize()
|
const { columns } = useWindowSize()
|
||||||
const { dashWidth } = getLayout(columns)
|
const { dashWidth } = getLayout(columns)
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" width={dashWidth}>
|
<Box flexDirection="column" width={dashWidth}>
|
||||||
<PeriodTabs active={period} />
|
{dayMode ? <DayBanner label={label ?? PERIOD_LABELS[period]} width={dashWidth} /> : <PeriodTabs active={period} />}
|
||||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} />
|
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsages={planUsages} label={label} dayMode={dayMode} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string): Promise<void> {
|
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise<void> {
|
||||||
await loadPricing()
|
await loadPricing()
|
||||||
const range = customRange ?? getPeriodRange(period)
|
const dayRange = initialDay ? getDayRange(initialDay) : null
|
||||||
|
const range = dayRange ?? customRange ?? getPeriodRange(period)
|
||||||
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||||
const planUsages = await getPlanUsages()
|
const planUsages = await getPlanUsages()
|
||||||
const isTTY = process.stdin.isTTY && process.stdout.isTTY
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY
|
||||||
|
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
|
||||||
patchStdoutForWindows()
|
patchStdoutForWindows()
|
||||||
if (isTTY) {
|
if (isTTY) {
|
||||||
const { waitUntilExit } = render(
|
const { waitUntilExit } = render(
|
||||||
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} />
|
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} />
|
||||||
)
|
)
|
||||||
await waitUntilExit()
|
await waitUntilExit()
|
||||||
} else {
|
} else {
|
||||||
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} />, { patchConsole: false })
|
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} />, { patchConsole: false })
|
||||||
unmount()
|
unmount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
src/main.ts
105
src/main.ts
|
|
@ -3,7 +3,7 @@ import { Command } from 'commander'
|
||||||
import { installMenubarApp } from './menubar-installer.js'
|
import { installMenubarApp } from './menubar-installer.js'
|
||||||
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
||||||
import { loadPricing, setModelAliases } from './models.js'
|
import { loadPricing, setModelAliases } from './models.js'
|
||||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
|
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from './parser.js'
|
||||||
import { convertCost } from './currency.js'
|
import { convertCost } from './currency.js'
|
||||||
import { renderStatusBar } from './format.js'
|
import { renderStatusBar } from './format.js'
|
||||||
import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js'
|
import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js'
|
||||||
|
|
@ -13,15 +13,10 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './d
|
||||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||||
import { renderDashboard } from './dashboard.js'
|
import { renderDashboard } from './dashboard.js'
|
||||||
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
|
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||||
import { runOptimize, scanAndDetect } from './optimize.js'
|
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||||
import { renderCompare } from './compare.js'
|
import { renderCompare } from './compare.js'
|
||||||
import { getAllProviders } from './providers/index.js'
|
import { getAllProviders } from './providers/index.js'
|
||||||
import {
|
|
||||||
installAntigravityStatusLineHook,
|
|
||||||
runAgyStatusLineHook,
|
|
||||||
uninstallAntigravityStatusLineHook,
|
|
||||||
} from './antigravity-statusline.js'
|
|
||||||
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
|
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
|
||||||
import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
|
import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
|
||||||
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
|
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
|
||||||
|
|
@ -361,6 +356,7 @@ program
|
||||||
.command('report', { isDefault: true })
|
.command('report', { isDefault: true })
|
||||||
.description('Interactive usage dashboard')
|
.description('Interactive usage dashboard')
|
||||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
||||||
|
.option('--day <date>', 'Single day to review (YYYY-MM-DD, today, or yesterday). Overrides --period when set')
|
||||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
|
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
|
||||||
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
|
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
|
||||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||||
|
|
@ -371,7 +367,12 @@ program
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
assertFormat(opts.format, ['tui', 'json'], 'report')
|
assertFormat(opts.format, ['tui', 'json'], 'report')
|
||||||
let customRange: DateRange | null = null
|
let customRange: DateRange | null = null
|
||||||
|
let daySelection: ReturnType<typeof parseDayFlag> = null
|
||||||
try {
|
try {
|
||||||
|
if (opts.day && (opts.from || opts.to)) {
|
||||||
|
throw new Error('--day cannot be combined with --from or --to')
|
||||||
|
}
|
||||||
|
daySelection = parseDayFlag(opts.day)
|
||||||
customRange = parseDateRangeFlags(opts.from, opts.to)
|
customRange = parseDateRangeFlags(opts.from, opts.to)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
@ -382,21 +383,23 @@ program
|
||||||
const period = toPeriod(opts.period)
|
const period = toPeriod(opts.period)
|
||||||
if (opts.format === 'json') {
|
if (opts.format === 'json') {
|
||||||
await loadPricing()
|
await loadPricing()
|
||||||
if (customRange) {
|
if (daySelection || customRange) {
|
||||||
const label = formatDateRangeLabel(opts.from, opts.to)
|
const range = daySelection?.range ?? customRange!
|
||||||
|
const label = daySelection?.label ?? formatDateRangeLabel(opts.from, opts.to)
|
||||||
|
const periodKey = daySelection ? 'day' : 'custom'
|
||||||
const projects = filterProjectsByName(
|
const projects = filterProjectsByName(
|
||||||
await parseAllSessions(customRange, opts.provider),
|
await parseAllSessions(range, opts.provider),
|
||||||
opts.project,
|
opts.project,
|
||||||
opts.exclude,
|
opts.exclude,
|
||||||
)
|
)
|
||||||
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, 'custom')), null, 2))
|
console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, periodKey)), null, 2))
|
||||||
} else {
|
} else {
|
||||||
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
|
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
|
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
|
||||||
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel)
|
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day)
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||||
|
|
@ -447,6 +450,10 @@ program
|
||||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||||
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
|
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
|
||||||
|
.option('--day <date>', 'Single day for menubar-json (YYYY-MM-DD, today, or yesterday). Overrides --period when set')
|
||||||
|
.option('--from <date>', 'Start date (YYYY-MM-DD) for custom range')
|
||||||
|
.option('--to <date>', 'End date (YYYY-MM-DD) for custom range')
|
||||||
|
.option('--days <dates>', 'Comma-separated dates (YYYY-MM-DD) for multi-day selection')
|
||||||
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
|
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
|
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
|
||||||
|
|
@ -454,7 +461,14 @@ program
|
||||||
const pf = opts.provider
|
const pf = opts.provider
|
||||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||||
if (opts.format === 'menubar-json') {
|
if (opts.format === 'menubar-json') {
|
||||||
const periodInfo = getDateRange(opts.period)
|
const daysSelection = parseDaysFlag(opts.days)
|
||||||
|
const customRange = daysSelection ? null : parseDateRangeFlags(opts.from, opts.to)
|
||||||
|
const daySelection = parseDayFlag(opts.day)
|
||||||
|
const periodInfo = daysSelection
|
||||||
|
? { range: daysSelection.range, label: daysSelection.label }
|
||||||
|
: customRange
|
||||||
|
? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) }
|
||||||
|
: daySelection ?? getDateRange(opts.period)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const todayRange: DateRange = { start: todayStart, end: now }
|
const todayRange: DateRange = { start: todayStart, end: now }
|
||||||
|
|
@ -462,6 +476,7 @@ program
|
||||||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||||
const rangeStartStr = toDateString(periodInfo.range.start)
|
const rangeStartStr = toDateString(periodInfo.range.start)
|
||||||
const rangeEndStr = toDateString(periodInfo.range.end)
|
const rangeEndStr = toDateString(periodInfo.range.end)
|
||||||
|
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
|
||||||
const isAllProviders = pf === 'all'
|
const isAllProviders = pf === 'all'
|
||||||
|
|
||||||
let todayAllProjects: ProjectSummary[] | null = null
|
let todayAllProjects: ProjectSummary[] | null = null
|
||||||
|
|
@ -491,26 +506,34 @@ program
|
||||||
cache = await hydrateCache()
|
cache = await hydrateCache()
|
||||||
const todayProjects = await getTodayAllProjects()
|
const todayProjects = await getTodayAllProjects()
|
||||||
const todayDays = await getTodayAllDays()
|
const todayDays = await getTodayAllDays()
|
||||||
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
|
const historicalDays = rangeStartStr <= historicalRangeEndStr
|
||||||
|
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
|
||||||
|
: []
|
||||||
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
|
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
|
||||||
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays
|
||||||
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
|
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
|
||||||
const isTodayOnly = opts.period === 'today'
|
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
|
||||||
if (isTodayOnly) {
|
if (isTodayOnly) {
|
||||||
scanProjects = todayProjects
|
scanProjects = todayProjects
|
||||||
scanRange = todayRange
|
scanRange = todayRange
|
||||||
} else {
|
} else {
|
||||||
scanProjects = fp(await parseAllSessions(periodInfo.range, 'all'))
|
const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all'))
|
||||||
|
scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects
|
||||||
scanRange = periodInfo.range
|
scanRange = periodInfo.range
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cache = await loadDailyCache()
|
cache = await loadDailyCache()
|
||||||
const fullProjects = fp(await parseAllSessions(periodInfo.range, pf))
|
const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf))
|
||||||
|
const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects
|
||||||
todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
|
todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
|
||||||
currentData = todayProviderData
|
currentData = todayProviderData
|
||||||
scanProjects = fullProjects
|
scanProjects = fullProjects
|
||||||
scanRange = periodInfo.range
|
scanRange = periodInfo.range
|
||||||
}
|
}
|
||||||
|
if (isAllProviders) {
|
||||||
|
currentData = buildPeriodData(periodInfo.label, scanProjects)
|
||||||
|
}
|
||||||
|
|
||||||
// PROVIDERS
|
// PROVIDERS
|
||||||
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
|
// For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
|
||||||
|
|
@ -519,10 +542,11 @@ program
|
||||||
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
|
const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
|
||||||
const providers: ProviderCost[] = []
|
const providers: ProviderCost[] = []
|
||||||
if (isAllProviders) {
|
if (isAllProviders) {
|
||||||
const allDaysForProviders = [
|
const unfilteredProviderDays = [
|
||||||
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
|
...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []),
|
||||||
...(await getTodayAllDays()).filter(d => d.date === todayStr),
|
...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr),
|
||||||
]
|
]
|
||||||
|
const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays
|
||||||
const providerTotals: Record<string, number> = {}
|
const providerTotals: Record<string, number> = {}
|
||||||
for (const d of allDaysForProviders) {
|
for (const d of allDaysForProviders) {
|
||||||
for (const [name, p] of Object.entries(d.providers)) {
|
for (const [name, p] of Object.entries(d.providers)) {
|
||||||
|
|
@ -866,45 +890,6 @@ program
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
program
|
|
||||||
.command('antigravity-hook')
|
|
||||||
.description('Install or remove exact Antigravity CLI usage capture')
|
|
||||||
.argument('<action>', 'install or uninstall')
|
|
||||||
.option('--force', 'Replace an existing custom Antigravity CLI statusLine command')
|
|
||||||
.action(async (action: string, opts: { force?: boolean }) => {
|
|
||||||
try {
|
|
||||||
if (action === 'install') {
|
|
||||||
const result = await installAntigravityStatusLineHook(!!opts.force)
|
|
||||||
console.log(result === 'already-installed'
|
|
||||||
? '\n Antigravity CLI usage capture is already installed.\n'
|
|
||||||
: '\n Antigravity CLI usage capture installed.\n')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (action === 'uninstall') {
|
|
||||||
const result = await uninstallAntigravityStatusLineHook()
|
|
||||||
console.log(result === 'not-installed'
|
|
||||||
? '\n Antigravity CLI usage capture is not installed.\n'
|
|
||||||
: result === 'restored'
|
|
||||||
? '\n Antigravity CLI usage capture removed; previous statusLine restored.\n'
|
|
||||||
: '\n Antigravity CLI usage capture removed.\n')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error('\n Usage: codeburn antigravity-hook <install|uninstall>\n')
|
|
||||||
process.exit(1)
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
console.error(`\n Antigravity hook failed: ${message}\n`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('agy-statusline-hook', { hidden: true })
|
|
||||||
.description('Internal Antigravity CLI statusLine hook')
|
|
||||||
.action(async () => {
|
|
||||||
await runAgyStatusLineHook()
|
|
||||||
})
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('currency [code]')
|
.command('currency [code]')
|
||||||
.description('Set display currency (e.g. codeburn currency GBP)')
|
.description('Set display currency (e.g. codeburn currency GBP)')
|
||||||
|
|
|
||||||
|
|
@ -2012,6 +2012,41 @@ function turnIsInDateRange(turn: ClassifiedTurn, dateRange: DateRange): boolean
|
||||||
return ts >= dateRange.start && ts <= dateRange.end
|
return ts >= dateRange.start && ts <= dateRange.end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function turnDayString(turn: ClassifiedTurn): string | null {
|
||||||
|
if (turn.assistantCalls.length === 0) return null
|
||||||
|
const ts = turn.assistantCalls[0]!.timestamp
|
||||||
|
if (!ts) return null
|
||||||
|
const d = new Date(ts)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterProjectsByDays(projects: ProjectSummary[], days: Set<string>): ProjectSummary[] {
|
||||||
|
const filtered: ProjectSummary[] = []
|
||||||
|
for (const project of projects) {
|
||||||
|
const sessions: SessionSummary[] = []
|
||||||
|
for (const session of project.sessions) {
|
||||||
|
const turns = session.turns.filter(turn => {
|
||||||
|
const ds = turnDayString(turn)
|
||||||
|
return ds !== null && days.has(ds)
|
||||||
|
})
|
||||||
|
if (turns.length === 0) continue
|
||||||
|
sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory))
|
||||||
|
}
|
||||||
|
if (sessions.length === 0) continue
|
||||||
|
filtered.push({
|
||||||
|
project: project.project,
|
||||||
|
projectPath: project.projectPath,
|
||||||
|
sessions,
|
||||||
|
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
|
||||||
|
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
|
||||||
|
}
|
||||||
|
|
||||||
export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] {
|
export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] {
|
||||||
const filtered: ProjectSummary[] = []
|
const filtered: ProjectSummary[] = []
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,69 @@ describe('codeburn report --format json daily[] one-shot fields (issue #279)', (
|
||||||
await rm(home, { recursive: true, force: true })
|
await rm(home, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters a single review day with --day', async () => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectDir = join(home, '.claude', 'projects', 'app')
|
||||||
|
await mkdir(projectDir, { recursive: true })
|
||||||
|
|
||||||
|
// runCli pins TZ=UTC, so these exact boundaries exercise the inclusive
|
||||||
|
// start/end of the selected calendar day without local-offset drift.
|
||||||
|
await writeFile(
|
||||||
|
join(projectDir, 'day-selector.jsonl'),
|
||||||
|
[
|
||||||
|
userLine('day-before', '2026-04-09T23:59:00Z'),
|
||||||
|
assistantNoEditLine('day-before', '2026-04-09T23:59:30Z', 'm-before'),
|
||||||
|
userLine('day-selected', '2026-04-10T00:00:00Z'),
|
||||||
|
assistantEditLine('day-selected', '2026-04-10T23:59:59Z', 'm-selected'),
|
||||||
|
userLine('day-after', '2026-04-11T00:00:00Z'),
|
||||||
|
assistantNoEditLine('day-after', '2026-04-11T00:00:30Z', 'm-after'),
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = runCli([
|
||||||
|
'--format', 'json',
|
||||||
|
'--day', '2026-04-10',
|
||||||
|
'--provider', 'claude',
|
||||||
|
], home)
|
||||||
|
|
||||||
|
expect(result.status).toBe(0)
|
||||||
|
const report = JSON.parse(result.stdout) as {
|
||||||
|
period: string
|
||||||
|
periodKey: string
|
||||||
|
daily: Array<{ date: string; calls: number; editTurns: number }>
|
||||||
|
projects: Array<{ sessions: number; calls: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(report.period).toBe('Day (2026-04-10)')
|
||||||
|
expect(report.periodKey).toBe('day')
|
||||||
|
expect(report.daily.map(d => d.date)).toEqual(['2026-04-10'])
|
||||||
|
expect(report.daily[0]?.calls).toBe(1)
|
||||||
|
expect(report.daily[0]?.editTurns).toBe(1)
|
||||||
|
expect(report.projects[0]?.sessions).toBe(1)
|
||||||
|
expect(report.projects[0]?.calls).toBe(1)
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects --day combined with --from/--to', async () => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = runCli([
|
||||||
|
'--format', 'json',
|
||||||
|
'--day', '2026-04-10',
|
||||||
|
'--from', '2026-04-10',
|
||||||
|
'--provider', 'claude',
|
||||||
|
], home)
|
||||||
|
|
||||||
|
expect(result.status).toBe(1)
|
||||||
|
expect(result.stderr).toContain('--day cannot be combined with --from or --to')
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -105,4 +105,53 @@ describe('codeburn status --format menubar-json', () => {
|
||||||
await rm(home, { recursive: true, force: true })
|
await rm(home, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters menubar payloads to a selected review day with --day', async () => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-day-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectDir = join(home, '.claude', 'projects', 'myapp')
|
||||||
|
await mkdir(projectDir, { recursive: true })
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(projectDir, 'session.jsonl'),
|
||||||
|
[
|
||||||
|
userLine('before', '2026-04-09T23:58:00Z'),
|
||||||
|
assistantLine('before', '2026-04-09T23:59:00Z', 'msg-before'),
|
||||||
|
userLine('selected', '2026-04-10T11:59:00Z'),
|
||||||
|
assistantLine('selected', '2026-04-10T12:00:00Z', 'msg-selected'),
|
||||||
|
userLine('after', '2026-04-11T00:00:00Z'),
|
||||||
|
assistantLine('after', '2026-04-11T00:01:00Z', 'msg-after'),
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = runCli([
|
||||||
|
'status',
|
||||||
|
'--format', 'menubar-json',
|
||||||
|
'--day', '2026-04-10',
|
||||||
|
'--provider', 'all',
|
||||||
|
'--no-optimize',
|
||||||
|
], home)
|
||||||
|
|
||||||
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||||
|
|
||||||
|
const payload = JSON.parse(result.stdout) as {
|
||||||
|
current: {
|
||||||
|
label: string
|
||||||
|
calls: number
|
||||||
|
sessions: number
|
||||||
|
topProjects: Array<{ sessions: number; sessionDetails: Array<{ date: string }> }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(payload.current.label).toBe('Day (2026-04-10)')
|
||||||
|
expect(payload.current.calls).toBe(1)
|
||||||
|
expect(payload.current.sessions).toBe(1)
|
||||||
|
expect(payload.current.topProjects).toHaveLength(1)
|
||||||
|
expect(payload.current.topProjects[0]?.sessions).toBe(1)
|
||||||
|
expect(payload.current.topProjects[0]?.sessionDetails.map(s => s.date)).toEqual(['2026-04-10'])
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||||
import { formatDateRangeLabel, parseDateRangeFlags } from '../src/cli-date.js'
|
import { formatDateRangeLabel, formatDayRangeLabel, parseDateRangeFlags, parseDayFlag, shiftDay } from '../src/cli-date.js'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
describe('parseDateRangeFlags', () => {
|
describe('parseDateRangeFlags', () => {
|
||||||
it('returns null when neither flag is provided', () => {
|
it('returns null when neither flag is provided', () => {
|
||||||
|
|
@ -87,3 +91,51 @@ describe('parseDateRangeFlags', () => {
|
||||||
expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today')
|
expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parseDayFlag', () => {
|
||||||
|
it('returns null when no day is provided', () => {
|
||||||
|
expect(parseDayFlag(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses an explicit day as local midnight through end of day', () => {
|
||||||
|
const selected = parseDayFlag('2026-04-10')
|
||||||
|
expect(selected).not.toBeNull()
|
||||||
|
expect(selected!.day).toBe('2026-04-10')
|
||||||
|
expect(selected!.label).toBe('Day (2026-04-10)')
|
||||||
|
expect(selected!.range.start.getFullYear()).toBe(2026)
|
||||||
|
expect(selected!.range.start.getMonth()).toBe(3)
|
||||||
|
expect(selected!.range.start.getDate()).toBe(10)
|
||||||
|
expect(selected!.range.start.getHours()).toBe(0)
|
||||||
|
expect(selected!.range.end.getDate()).toBe(10)
|
||||||
|
expect(selected!.range.end.getHours()).toBe(23)
|
||||||
|
expect(selected!.range.end.getMinutes()).toBe(59)
|
||||||
|
expect(selected!.range.end.getSeconds()).toBe(59)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves yesterday as the previous local calendar day after midnight', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date(2026, 4, 23, 0, 5, 0))
|
||||||
|
|
||||||
|
const selected = parseDayFlag('yesterday')
|
||||||
|
|
||||||
|
expect(selected!.day).toBe('2026-05-22')
|
||||||
|
expect(selected!.range.start.getDate()).toBe(22)
|
||||||
|
expect(selected!.range.end.getDate()).toBe(22)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports today and day shifting', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date(2026, 4, 23, 12, 0, 0))
|
||||||
|
|
||||||
|
expect(parseDayFlag('today')!.day).toBe('2026-05-23')
|
||||||
|
expect(formatDayRangeLabel('2026-05-22')).toBe('Day (2026-05-22)')
|
||||||
|
expect(shiftDay('2026-05-22', -1)).toBe('2026-05-21')
|
||||||
|
expect(shiftDay('2026-05-22', 1)).toBe('2026-05-23')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed or overflowing day values', () => {
|
||||||
|
expect(() => parseDayFlag('May 22')).toThrow('Invalid date format')
|
||||||
|
expect(() => parseDayFlag('2026-02-31')).toThrow('Invalid date "2026-02-31"')
|
||||||
|
expect(() => shiftDay('2026-13-01', 1)).toThrow('Invalid date "2026-13-01"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue