on master help sign in
feat: show z.ai peak hours in menu bar + sparkline

- Menu bar: ๐Ÿ”ถ appended to z.ai segment when in peak hours (e.g. ๐Ÿ…‰12% 3%๐Ÿ”ถ) - Sparkline: orange tinted background when in peak hours - Popover: already had peak hours notice (previous commit) - Shared ViewModel.zaiInPeakHours static property - 3 new tests for peak hours in menu bar

commit440eca7f87414cb868763c6cd232e111d5642c15 authoriko <mail@iko.soy> dateFri 17 Jul 2026, 14:17 (3 months ago) parent82213bb
4 files changed+121โˆ’41
UsageMenubar/MenuView.swift+18โˆ’12 UsageMenubar/UsageMenubarApp.swift+2โˆ’1 UsageMenubar/ViewModel.swift+17โˆ’4 UsageMenubarTests/StatusBarTextTests.swift+84โˆ’24
UsageMenubar/MenuView.swift+18โˆ’12
@@ -410,11 +410,23 @@ struct MenuView: View {
410410 // Peak hours notice.
411411 zaiPeakHours
412412
413โˆ’ // Sparkline for the z.ai 5-hour % history.
414โˆ’ sparkline(
415โˆ’ values: viewModel.zaiSparklineValues,
416โˆ’ color: Self.usageColor(zaiFiveHourPercent ?? 0)
417โˆ’ )
413+ // Sparkline for the z.ai 5-hour % history, with an orange tint
414+ // when currently in peak hours.
415+ if viewModel.zaiSparklineValues.count >= 2 {
416+ Sparkline(
417+ values: viewModel.zaiSparklineValues,
418+ color: Self.usageColor(zaiFiveHourPercent ?? 0)
419+ )
420+ .frame(height: 26)
421+ .padding(.top, 4)
422+ .transition(.opacity)
423+ .background(
424+ ViewModel.zaiInPeakHours
425+ ? RoundedRectangle(cornerRadius: 4, style: .continuous)
426+ .fill(Color.orange.opacity(0.08))
427+ : nil
428+ )
429+ }
418430 }
419431 }
420432
@@ -445,7 +457,7 @@ struct MenuView: View {
445457 /// z.ai peak hours: 14:00โ€“18:00 CST (UTC+8), i.e. 06:00โ€“10:00 UTC.
446458 /// Limits are most likely to bind during this window.
447459 private var zaiPeakHours: some View {
448โˆ’ let inPeak = Self.isZaiPeakHoursNow
460+ let inPeak = ViewModel.zaiInPeakHours
449461
450462 return HStack(spacing: 6) {
451463 Circle()
@@ -458,12 +470,6 @@ struct MenuView: View {
458470 }
459471 }
460472
461โˆ’ /// Whether the current UTC time falls within z.ai's peak hours (06:00โ€“10:00 UTC).
462โˆ’ private static var isZaiPeakHoursNow: Bool {
463โˆ’ let hour = Calendar(identifier: .gregorian).component(.hour, from: Date())
464โˆ’ return hour >= 6 && hour < 10
465โˆ’ }
466โˆ’
467473 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
468474 private static func zaiResetsIn(from date: Date) -> String {
469475 let interval = date.timeIntervalSince(Date())
UsageMenubar/UsageMenubarApp.swift+2โˆ’1
@@ -89,7 +89,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
8989 claudeFiveHourPercent: fiveHour,
9090 claudeSevenDayPercent: sevenDay,
9191 zaiFiveHourPercent: zaiFiveHour,
92โˆ’ zaiWeeklyPercent: zaiWeekly
92+ zaiWeeklyPercent: zaiWeekly,
93+ zaiInPeakHours: ViewModel.zaiInPeakHours
9394 )
9495 }
9596 .removeDuplicates()
UsageMenubar/ViewModel.swift+17โˆ’4
@@ -241,8 +241,9 @@ final class ViewModel: ObservableObject {
241241 /// segment prefixed with a provider emoji; segments are separated by ` ยท `
242242 /// (middle dot). Within a segment, windows are listed as bare percentages
243243 /// separated by spaces. An unconfigured service produces no segment.
244+ /// When z.ai is in peak hours, a ๐Ÿ”ถ marker is appended to the z.ai segment.
244245 ///
245โˆ’ /// Example: `โšก42 ยท ๐Ÿ…ฒ62% 8% ยท ๐Ÿ…‰12% 3%`
246+ /// Example: `โšก42 ยท ๐Ÿ…ฒ62% 8% ยท ๐Ÿ…‰12% 3%๐Ÿ”ถ`
246247 ///
247248 /// Takes its values as parameters rather than reading the properties: `@Published`
248249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -254,7 +255,8 @@ final class ViewModel: ObservableObject {
254255 claudeFiveHourPercent: Int?,
255256 claudeSevenDayPercent: Int?,
256257 zaiFiveHourPercent: Int?,
257โˆ’ zaiWeeklyPercent: Int?
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
258260 ) -> String {
259261 var segments: [String] = []
260262
@@ -284,7 +286,11 @@ final class ViewModel: ObservableObject {
284286 zaiWindows.append("\(zaiWeekly)%")
285287 }
286288 if !zaiWindows.isEmpty {
287โˆ’ segments.append("๐Ÿ…‰" + zaiWindows.joined(separator: " "))
289+ var segment = "๐Ÿ…‰" + zaiWindows.joined(separator: " ")
290+ if zaiInPeakHours {
291+ segment += "๐Ÿ”ถ"
292+ }
293+ segments.append(segment)
288294 }
289295
290296 if segments.isEmpty {
@@ -304,10 +310,17 @@ final class ViewModel: ObservableObject {
304310 claudeFiveHourPercent: claudeFiveHourPercent,
305311 claudeSevenDayPercent: claudeSevenDayPercent,
306312 zaiFiveHourPercent: zaiFiveHourPercent,
307โˆ’ zaiWeeklyPercent: zaiWeeklyPercent
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
308315 )
309316 }
310317
318+ /// Whether z.ai is currently in peak hours (06:00โ€“10:00 UTC).
319+ static var zaiInPeakHours: Bool {
320+ let hour = Calendar(identifier: .gregorian).component(.hour, from: Date())
321+ return hour >= 6 && hour < 10
322+ }
323+
311324 /// The formatted balance string for display in the popover.
312325 var formattedBalance: String {
313326 guard let balance = balance else { return "?" }
UsageMenubarTests/StatusBarTextTests.swift+84โˆ’24
@@ -15,7 +15,8 @@ final class StatusBarTextTests: XCTestCase {
1515 balance: 42, isLoading: false,
1616 hyperConfigured: true,
1717 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19+ zaiInPeakHours: false
1920 )
2021 XCTAssertEqual(title, "โšก42 ยท \u{1F172}62% 8%")
2122 }
@@ -25,7 +26,8 @@ final class StatusBarTextTests: XCTestCase {
2526 balance: 42, isLoading: false,
2627 hyperConfigured: true,
2728 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
28โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
29+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30+ zaiInPeakHours: false
2931 )
3032 XCTAssertEqual(title, "โšก42")
3133 }
@@ -35,7 +37,8 @@ final class StatusBarTextTests: XCTestCase {
3537 balance: nil, isLoading: false,
3638 hyperConfigured: false,
3739 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
38โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
40+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41+ zaiInPeakHours: false
3942 )
4043 XCTAssertEqual(title, "\u{1F172}62% 8%")
4144 }
@@ -45,7 +48,8 @@ final class StatusBarTextTests: XCTestCase {
4548 balance: nil, isLoading: true,
4649 hyperConfigured: false,
4750 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
48โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
51+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52+ zaiInPeakHours: false
4953 )
5054 XCTAssertEqual(title, "โ€ฆ")
5155 }
@@ -55,7 +59,8 @@ final class StatusBarTextTests: XCTestCase {
5559 balance: nil, isLoading: false,
5660 hyperConfigured: false,
5761 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
58โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
62+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63+ zaiInPeakHours: false
5964 )
6065 XCTAssertEqual(title, "")
6166 }
@@ -68,7 +73,8 @@ final class StatusBarTextTests: XCTestCase {
6873 balance: nil, isLoading: true,
6974 hyperConfigured: false,
7075 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
71โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
76+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77+ zaiInPeakHours: false
7278 )
7379 XCTAssertEqual(title, "\u{1F172}62%")
7480 }
@@ -78,7 +84,8 @@ final class StatusBarTextTests: XCTestCase {
7884 balance: 42, isLoading: true,
7985 hyperConfigured: true,
8086 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
81โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
87+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88+ zaiInPeakHours: false
8289 )
8390 XCTAssertEqual(title, "โšก42 ยท \u{1F172}62% 8%")
8491 }
@@ -88,7 +95,8 @@ final class StatusBarTextTests: XCTestCase {
8895 balance: 42, isLoading: false,
8996 hyperConfigured: true,
9097 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
91โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
98+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99+ zaiInPeakHours: false
92100 )
93101 XCTAssertEqual(title, "โšก42 ยท \u{1F172}8%")
94102 }
@@ -98,7 +106,8 @@ final class StatusBarTextTests: XCTestCase {
98106 balance: 42, isLoading: false,
99107 hyperConfigured: true,
100108 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
101โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
109+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110+ zaiInPeakHours: false
102111 )
103112 XCTAssertEqual(title, "โšก42")
104113 }
@@ -108,7 +117,8 @@ final class StatusBarTextTests: XCTestCase {
108117 balance: nil, isLoading: false,
109118 hyperConfigured: false,
110119 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
111โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
120+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121+ zaiInPeakHours: false
112122 )
113123 XCTAssertEqual(title, "\u{1F172}62%")
114124 }
@@ -118,7 +128,8 @@ final class StatusBarTextTests: XCTestCase {
118128 balance: nil, isLoading: false,
119129 hyperConfigured: false,
120130 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
121โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
131+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132+ zaiInPeakHours: false
122133 )
123134 XCTAssertEqual(title, "\u{1F172}8%")
124135 }
@@ -132,7 +143,8 @@ final class StatusBarTextTests: XCTestCase {
132143 balance: 42, isLoading: false,
133144 hyperConfigured: false,
134145 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
135โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
146+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147+ zaiInPeakHours: false
136148 )
137149 XCTAssertEqual(title, "")
138150 }
@@ -142,7 +154,8 @@ final class StatusBarTextTests: XCTestCase {
142154 balance: nil, isLoading: true,
143155 hyperConfigured: false,
144156 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
145โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
157+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158+ zaiInPeakHours: false
146159 )
147160 XCTAssertEqual(title, "โ€ฆ")
148161 }
@@ -152,7 +165,8 @@ final class StatusBarTextTests: XCTestCase {
152165 balance: nil, isLoading: false,
153166 hyperConfigured: false,
154167 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
155โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
168+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169+ zaiInPeakHours: false
156170 )
157171 XCTAssertEqual(title, "\u{1F172}62% 8%")
158172 }
@@ -164,7 +178,8 @@ final class StatusBarTextTests: XCTestCase {
164178 balance: nil, isLoading: false,
165179 hyperConfigured: false,
166180 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
167โˆ’ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
181+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182+ zaiInPeakHours: false
168183 )
169184 XCTAssertEqual(title, "\u{1F149}12%")
170185 }
@@ -174,7 +189,8 @@ final class StatusBarTextTests: XCTestCase {
174189 balance: 42, isLoading: false,
175190 hyperConfigured: true,
176191 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
177โˆ’ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
192+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193+ zaiInPeakHours: false
178194 )
179195 XCTAssertEqual(title, "โšก42 ยท \u{1F149}12%")
180196 }
@@ -184,7 +200,8 @@ final class StatusBarTextTests: XCTestCase {
184200 balance: nil, isLoading: false,
185201 hyperConfigured: false,
186202 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
187โˆ’ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
203+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204+ zaiInPeakHours: false
188205 )
189206 XCTAssertEqual(title, "\u{1F172}62% 8% ยท \u{1F149}12%")
190207 }
@@ -194,7 +211,8 @@ final class StatusBarTextTests: XCTestCase {
194211 balance: 42, isLoading: false,
195212 hyperConfigured: true,
196213 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
197โˆ’ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
214+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215+ zaiInPeakHours: false
198216 )
199217 XCTAssertEqual(title, "โšก42 ยท \u{1F172}62% 8% ยท \u{1F149}12% 3%")
200218 }
@@ -204,7 +222,8 @@ final class StatusBarTextTests: XCTestCase {
204222 balance: 42, isLoading: false,
205223 hyperConfigured: true,
206224 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
207โˆ’ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0
225+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226+ zaiInPeakHours: false
208227 )
209228 XCTAssertEqual(title, "โšก42")
210229 }
@@ -214,7 +233,8 @@ final class StatusBarTextTests: XCTestCase {
214233 balance: nil, isLoading: true,
215234 hyperConfigured: false,
216235 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
217โˆ’ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
236+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237+ zaiInPeakHours: false
218238 )
219239 XCTAssertEqual(title, "\u{1F149}12%")
220240 }
@@ -226,7 +246,8 @@ final class StatusBarTextTests: XCTestCase {
226246 balance: nil, isLoading: false,
227247 hyperConfigured: false,
228248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
229โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
249+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250+ zaiInPeakHours: false
230251 )
231252 XCTAssertEqual(title, "\u{1F149}3%")
232253 }
@@ -236,7 +257,8 @@ final class StatusBarTextTests: XCTestCase {
236257 balance: nil, isLoading: false,
237258 hyperConfigured: false,
238259 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
239โˆ’ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
260+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261+ zaiInPeakHours: false
240262 )
241263 XCTAssertEqual(title, "\u{1F149}12% 3%")
242264 }
@@ -246,7 +268,8 @@ final class StatusBarTextTests: XCTestCase {
246268 balance: nil, isLoading: false,
247269 hyperConfigured: false,
248270 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249โˆ’ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0
271+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272+ zaiInPeakHours: false
250273 )
251274 XCTAssertEqual(title, "\u{1F149}12%")
252275 }
@@ -256,11 +279,48 @@ final class StatusBarTextTests: XCTestCase {
256279 balance: nil, isLoading: true,
257280 hyperConfigured: false,
258281 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
259โˆ’ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
282+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
283+ zaiInPeakHours: false
260284 )
261285 XCTAssertEqual(title, "\u{1F149}3%")
262286 }
263287
288+ // MARK: - z.ai Peak Hours
289+
290+ func testPeakHoursAddsDiamondToZaiSegment() {
291+ let title = ViewModel.statusBarText(
292+ balance: 42, isLoading: false,
293+ hyperConfigured: true,
294+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
295+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
296+ zaiInPeakHours: true
297+ )
298+ XCTAssertEqual(title, "โšก42 ยท \u{1F172}62% 8% ยท \u{1F149}12% 3%\u{1F536}")
299+ }
300+
301+ func testPeakHoursWithZaiOnly() {
302+ let title = ViewModel.statusBarText(
303+ balance: nil, isLoading: false,
304+ hyperConfigured: false,
305+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
306+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
307+ zaiInPeakHours: true
308+ )
309+ XCTAssertEqual(title, "\u{1F149}12%\u{1F536}")
310+ }
311+
312+ func testPeakHoursIgnoredWhenZaiNotConfigured() {
313+ let title = ViewModel.statusBarText(
314+ balance: 42, isLoading: false,
315+ hyperConfigured: true,
316+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
317+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
318+ zaiInPeakHours: true
319+ )
320+ // No z.ai segment, so peak marker has nothing to attach to.
321+ XCTAssertEqual(title, "โšก42 ยท \u{1F172}62% 8%")
322+ }
323+
264324 // MARK: - Claude Window Percentages
265325
266326 func testFiveHourPercentRounds() {