Add configurable menubar status period

This commit is contained in:
ozymandiashh 2026-05-11 17:54:50 +03:00
parent d9acd8c4cd
commit 426a71c413
6 changed files with 177 additions and 19 deletions

View file

@ -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)

View file

@ -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`):

View file

@ -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<PayloadCacheKey> = []
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

View file

@ -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

View file

@ -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 }

View file

@ -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")
}
}