diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dd43d7..21d9bd5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Added (macOS menubar) +- **Configurable menubar status period.** Settings now lets the status item + show Today, Week, Month, or 6 Months instead of always pinning the text to + today's spend. The choice persists in `CodeBurnMenubarPeriod` (`today`, + `week`, `month`, `sixMonths`) and non-today values carry a short suffix + such as `$42 / mo` so the menu bar remains unambiguous. Closes #291. + ## 0.9.8 - 2026-05-10 ### Added (CLI) diff --git a/README.md b/README.md index b3700224..5e0e2c44 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,15 @@ codeburn menubar One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift and SwiftUI app lives in `mac/` (see `mac/README.md` for build details). -The menubar icon always shows today's spend (so $0 is normal if you have not used AI tools today). Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds. +The menubar icon shows the spend period selected in Settings (Today by default; Week, Month, and 6 Months are also available). Non-today periods add a short suffix such as `$42 / mo` so the menu bar value stays clear. Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds. + +You can also set the menubar metric from Terminal: + +```bash +defaults write org.agentseal.codeburn-menubar CodeBurnMenubarPeriod -string month +``` + +Allowed values are `today`, `week`, `month`, and `sixMonths`. Relaunch the app to apply external defaults changes. **Compact mode** shrinks the menubar item to fit the text, dropping decimals (e.g. `$110` instead of `$110.20`): diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 00b27e8b..91e43695 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -2,6 +2,7 @@ import Foundation import Observation private let cacheTTLSeconds: TimeInterval = 30 +private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod" struct CachedPayload { let payload: MenubarPayload @@ -19,6 +20,9 @@ struct PayloadCacheKey: Hashable { final class AppStore { var selectedProvider: ProviderFilter = .all var selectedPeriod: Period = .today + var menubarPeriod: Period = Period.savedMenubarPeriod() { + didSet { menubarPeriod.persistAsMenubarDefault() } + } var selectedInsight: InsightMode = .trend var accentPreset: AccentPreset = ThemeState.shared.preset { didSet { ThemeState.shared.preset = accentPreset } @@ -71,12 +75,17 @@ final class AppStore { cache[currentKey]?.payload ?? .empty } - /// Today (across all providers) is pinned for the always-visible menubar icon, independent of - /// the popover's selected period or provider. + /// Today (across all providers) backs day-specific views in the popover. var todayPayload: MenubarPayload? { cache[PayloadCacheKey(period: .today, provider: .all)]?.payload } + /// All-provider payload for the user-selected menubar status metric. The + /// popover's visible period/provider can differ from this setting. + var menubarPayload: MenubarPayload? { + cache[PayloadCacheKey(period: menubarPeriod, provider: .all)]?.payload + } + /// All-provider payload for the selected period. Used by the tab strip to show /// per-provider costs that match the active period, not just today. var periodAllPayload: MenubarPayload? { @@ -132,6 +141,15 @@ final class AppStore { } } + func setMenubarPeriod(_ period: Period) { + guard Period.menubarMetricCases.contains(period) else { return } + guard menubarPeriod != period else { return } + menubarPeriod = period + Task { [weak self] in + await self?.refreshQuietly(period: period) + } + } + private var inFlightKeys: Set = [] func resetLoadingState() { @@ -812,6 +830,59 @@ enum Period: String, CaseIterable, Identifiable { case .all: "all" } } + + /// Status item metrics intentionally stay to the coarse Settings choices. + /// The popover still offers 30 Days, but it is not a persisted status metric. + static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all] + + var menubarMetricLabel: String { + switch self { + case .today: "Today" + case .sevenDays: "Week" + case .thirtyDays: "30 Days" + case .month: "Month" + case .all: "6 Months" + } + } + + var menubarDefaultsValue: String { + switch self { + case .today: "today" + case .sevenDays: "week" + case .thirtyDays: "30days" + case .month: "month" + case .all: "sixMonths" + } + } + + init(menubarDefaultsValue: String?) { + switch menubarDefaultsValue { + case "today": self = .today + case "week", "sevenDays": self = .sevenDays + case "month": self = .month + case "sixMonths", "all": self = .all + default: self = .today + } + } + + static func savedMenubarPeriod(defaults: UserDefaults = .standard) -> Period { + Period(menubarDefaultsValue: defaults.string(forKey: menubarPeriodDefaultsKey)) + } + + func persistAsMenubarDefault(defaults: UserDefaults = .standard) { + let period = Period.menubarMetricCases.contains(self) ? self : Period.today + defaults.set(period.menubarDefaultsValue, forKey: menubarPeriodDefaultsKey) + } + + func menubarSuffix(compact: Bool) -> String { + switch self { + case .today: "" + case .sevenDays: compact ? "/wk" : " / wk" + case .thirtyDays: compact ? "/30d" : " / 30d" + case .month: compact ? "/mo" : " / mo" + case .all: compact ? "/6mo" : " / 6mo" + } + } } /// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 5868258e..4b62b09a 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -239,9 +239,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let generation = forceRefreshGeneration forceRefreshTask = Task { - async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true) - async let today: Void = store.refreshQuietly(period: .today) - _ = await (main, today) + await refreshUsagePayloads(force: true, showLoading: true) refreshStatusButton() await MainActor.run { [weak self] in guard let self, self.forceRefreshGeneration == generation else { return } @@ -252,6 +250,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } + private func refreshUsagePayloads(force: Bool, showLoading: Bool = false) async { + let menubarPeriod = store.menubarPeriod + if store.selectedProvider == .all && store.selectedPeriod == menubarPeriod { + await store.refresh(includeOptimize: false, force: force, showLoading: showLoading) + } else { + async let visible: Void = store.refresh(includeOptimize: false, force: force, showLoading: showLoading) + async let menubar: Void = store.refreshQuietly(period: menubarPeriod) + _ = await (visible, menubar) + } + } + /// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where /// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise /// fetched live in the background. @@ -297,13 +306,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // observer plus the loop's natural tick). let sinceLast = Date().timeIntervalSince(self.lastRefreshTime) if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) { - if self.store.selectedPeriod != .today || self.store.selectedProvider != .all { - async let quiet: Void = self.store.refreshQuietly(period: .today) - async let main: Void = self.store.refresh(includeOptimize: false, force: true) - _ = await (quiet, main) - } else { - await self.store.refresh(includeOptimize: false, force: true) - } + await self.refreshUsagePayloads(force: true) self.lastRefreshTime = Date() self.refreshStatusButton() } @@ -338,7 +341,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // "Refresh Now" should refresh the menubar payload AND every // connected provider's live quota — the user's intent is "make // this match reality right now." - async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true) + async let payload: Void = self.refreshUsagePayloads(force: true, showLoading: true) async let claude: Bool = self.store.refreshSubscriptionReportingSuccess() async let codex: Bool = self.store.refreshCodexReportingSuccess() _ = await payload @@ -368,7 +371,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { withObservationTracking { [weak self] in guard let self else { return } _ = self.store.payload - _ = self.store.todayPayload + _ = self.store.menubarPeriod + _ = self.store.menubarPayload // Track currency so the menubar title catches up immediately on // currency switch instead of waiting for the next 30s payload tick. _ = self.store.currency @@ -474,13 +478,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { attachment.bounds = CGRect(x: 0, y: -3, width: size.width, height: size.height) } - let hasPayload = store.todayPayload != nil + let menubarPeriod = store.menubarPeriod + let menubarPayload = store.menubarPayload + let hasPayload = menubarPayload != nil let compact = isCompact let fallback = compact ? "$-" : "$—" - let formatted = store.todayPayload?.current.cost + let formatted = menubarPayload?.current.cost + let suffix = menubarPeriod.menubarSuffix(compact: compact) let valueText = compact - ? (formatted?.asCompactCurrencyWhole() ?? fallback) - : " " + (formatted?.asCompactCurrency() ?? fallback) + ? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix + : " " + (formatted?.asCompactCurrency() ?? fallback) + suffix var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0] if !hasPayload { @@ -491,6 +498,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { composed.append(NSAttributedString(attachment: attachment)) composed.append(NSAttributedString(string: valueText, attributes: textAttrs)) button.attributedTitle = composed + button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" } // MARK: - Popover diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index a4c35853..e6f5ac60 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -40,6 +40,15 @@ private struct GeneralSettingsTab: View { Text(code).tag(code) } } + Picker("Menubar metric", selection: Binding( + get: { store.menubarPeriod }, + set: { store.setMenubarPeriod($0) } + )) { + ForEach(Period.menubarMetricCases) { period in + Text(period.menubarMetricLabel).tag(period) + } + } + .pickerStyle(.menu) Picker("Accent", selection: Binding( get: { store.accentPreset }, set: { store.accentPreset = $0 } diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift new file mode 100644 index 00000000..d99bc8f7 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Menubar period settings") +struct MenubarPeriodSettingsTests { + @Test("settings picker exposes the requested status periods") + func exposesRequestedPeriods() { + #expect(Period.menubarMetricCases == [.today, .sevenDays, .month, .all]) + } + + @Test("defaults values map to periods") + func mapsDefaultsValues() { + #expect(Period(menubarDefaultsValue: "today") == .today) + #expect(Period(menubarDefaultsValue: "week") == .sevenDays) + #expect(Period(menubarDefaultsValue: "month") == .month) + #expect(Period(menubarDefaultsValue: "sixMonths") == .all) + #expect(Period(menubarDefaultsValue: "all") == .all) + #expect(Period(menubarDefaultsValue: "30days") == .today) + #expect(Period(menubarDefaultsValue: "bogus") == .today) + #expect(Period(menubarDefaultsValue: nil) == .today) + } + + @Test("periods persist canonical defaults values") + func persistsCanonicalDefaultsValues() throws { + let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + Period.sevenDays.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "week") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .sevenDays) + + Period.all.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "sixMonths") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .all) + + Period.thirtyDays.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "today") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .today) + } + + @Test("non-today periods render compact and regular suffixes") + func rendersSuffixes() { + #expect(Period.today.menubarSuffix(compact: false) == "") + #expect(Period.sevenDays.menubarSuffix(compact: false) == " / wk") + #expect(Period.month.menubarSuffix(compact: false) == " / mo") + #expect(Period.all.menubarSuffix(compact: false) == " / 6mo") + #expect(Period.sevenDays.menubarSuffix(compact: true) == "/wk") + #expect(Period.month.menubarSuffix(compact: true) == "/mo") + #expect(Period.all.menubarSuffix(compact: true) == "/6mo") + } +}