diff --git a/CHANGELOG.md b/CHANGELOG.md index d96ee94aa..1b65a4ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.29.1 — Unreleased ### Added +- Display: add optional workday markers for weekly progress bars (#1102). Thanks @Yuxin-Qiao! ### Fixed - Menu bar: show extra-usage spend as currency text for Claude and Cursor when that metric is selected (#1107). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift index 89505ac5d..abe15e287 100644 --- a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -18,4 +18,63 @@ extension UsageMenuCardView.Model { .map { showUsed ? 100 - Double($0) : Double($0) } .filter { $0 > 0 && $0 < 100 } } + + /// Merges quota warning markers with optional work-day boundary markers. + /// Preserves original warning-marker ordering when workdayMarkers is empty, + /// sorts the combined set when workday markers are present. + static func mergedMarkerPercents( + warningMarkers: [Double], + workdayMarkers: [Double]) -> [Double] + { + let combined = warningMarkers + workdayMarkers + return workdayMarkers.isEmpty ? combined : combined.sorted() + } + + /// Combines quota warning markers with optional work-day boundary markers + /// into a single sorted array. Workday markers are only applied when + /// includeWorkdayMarkers is true and windowMinutes == 10080. + static func markerPercents( + thresholds: [Int]?, + showUsed: Bool, + workDays: Int?, + windowMinutes: Int?, + includeWorkdayMarkers: Bool) -> [Double] + { + let warningMarkers = Self.warningMarkerPercents(thresholds: thresholds, showUsed: showUsed) + let workdayMarkers = includeWorkdayMarkers + ? workDayMarkerPercents(workDays: workDays, windowMinutes: windowMinutes) + : [] + return Self.mergedMarkerPercents(warningMarkers: warningMarkers, workdayMarkers: workdayMarkers) + } + + static func weeklyMarkerPercents(input: Input, windowMinutes: Int?) -> [Double] { + UsageMenuCardView.Model.markerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed, + workDays: input.workDaysPerWeek, + windowMinutes: windowMinutes, + includeWorkdayMarkers: true) + } + + static func codexLaneMarkerPercents( + input: Input, + lane: CodexConsumerProjection.RateLane, + windowMinutes: Int?) -> [Double] + { + UsageMenuCardView.Model.markerPercents( + thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + showUsed: input.usageBarsShowUsed, + workDays: input.workDaysPerWeek, + windowMinutes: windowMinutes, + includeWorkdayMarkers: lane == .weekly) + } +} + +/// Returns boundary percentages for work day markers on a weekly progress bar. +/// Only valid when windowMinutes == 10080 (standard 7-day week). +/// nil workDays means feature is disabled. +func workDayMarkerPercents(workDays: Int?, windowMinutes: Int?) -> [Double] { + guard workDays != nil, windowMinutes == 10080 else { return [] } + guard let wd = workDays, wd >= 2, wd <= 7 else { return [] } + return (1.. SettingsStore { let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 4182f310d..7c3b70790 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1068,6 +1068,33 @@ struct SettingsStoreTests { await expectObservation(for: .weekly, thresholds: [80, 40]) } + @Test + func `menu observation token updates on weekly progress work days changes`() async throws { + let suite = "SettingsStoreTests-observation-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.weeklyProgressWorkDays = 5 + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + @Test func `config backed settings trigger observation`() async throws { let suite = "SettingsStoreTests-observation-config"