on master help sign in
9 files changed+230−89
since splitsnapshots
unifiedsplit
CLAUDE.md+2−2 README.md+6−6 UsageMenubar/Info.plist+1−1 UsageMenubar/KeychainHelper.swift+2−2 UsageMenubar/MenuView.swift+58−7 UsageMenubar/UsageMenubarApp.swift+2−1 UsageMenubar/ViewModel.swift+49−20 UsageMenubarTests/StatusBarTextTests.swift+106−46 project.yml+4−4
CLAUDE.md+2−2
@@ -20,8 +20,8 @@ click-to-copy on the headline, and threshold-crossing notifications.
2020- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
2121- `VersionFormatter.swift` — Version string formatting
2222
23−The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
24−`📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and `📆{percent}%`
23+The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24+`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
2525(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
2626service is not configured (no API key or no credentials). An unconfigured service
2727produces no placeholder in the menu bar. The three fetches are independent —
README.md+6−6
@@ -9,8 +9,8 @@ A macOS menu bar application that displays usage limits and credit balances for
99## Features
1010
1111- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12−- 🤖 **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13−- 🤖 **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
12+- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13+- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
1414- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
1515- 🖱️ **Right-click menu** — quick access to Refresh and Quit
1616- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
@@ -24,10 +24,10 @@ Each service shows an emoji-prefixed segment when configured and above 0%:
2424| Service | Emoji | Example |
2525|---------|-------|---------|
2626| Hyper balance | ⚡ | `⚡42` |
27−| Claude 5-hour | 🕐 | `🕐62%` |
28−| Claude 7-day | 📅 | `📅8%` |
29−| z.ai 5-hour | 🤖 | `🤖12%` |
30−| z.ai weekly | 📆 | `📆3%` |
27+| Claude 5-hour | ⁕ | `⁕62%` |
28+| Claude 7-day | ⁕ | `⁕8%` |
29+| z.ai 5-hour | Z | `Z12%` |
30+| z.ai weekly | Z | `Z3%` |
3131
3232Unconfigured services don't appear at all — no placeholder, no icon.
3333
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
77 <key>CFBundleDisplayName</key>
88 <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>com.ilyakooo0.usage-menubar</string>
10+ <string>soy.iko.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
8282
8383/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
8484enum KeychainHelper {
85− private static let service = "com.ilyakooo0.usage-menubar"
85+ private static let service = "soy.iko.usage-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112112/// Uses the same keychain service as the Hyper key but a different account, so the
113113/// two keys coexist without clobbering each other.
114114enum ZaiKeychainHelper {
115− private static let service = "com.ilyakooo0.usage-menubar"
115+ private static let service = "soy.iko.usage-menubar"
116116 private static let account = "zai-api-key"
117117
118118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+58−7
@@ -259,7 +259,7 @@ struct MenuView: View {
259259 VStack(spacing: 8) {
260260 HStack(alignment: .firstTextBaseline, spacing: 8) {
261261 headlineNumber(
262− text: "🕐 \(percent)%",
262+ text: "⁕ \\(percent)%",
263263 color: Self.usageColor(percent),
264264 trend: viewModel.claudeTrend,
265265 copiedConfirmation: viewModel.copiedClaudeConfirmation,
@@ -379,7 +379,7 @@ struct MenuView: View {
379379 VStack(spacing: 8) {
380380 HStack(alignment: .firstTextBaseline, spacing: 8) {
381381 headlineNumber(
382− text: "🤖 \(fiveHour)%",
382+ text: "Z \\(fiveHour)%",
383383 color: Self.usageColor(fiveHour),
384384 trend: viewModel.zaiTrend,
385385 copiedConfirmation: viewModel.copiedZaiConfirmation,
@@ -407,11 +407,26 @@ struct MenuView: View {
407407 }
408408 }
409409
410− // Sparkline for the z.ai 5-hour % history.
411− sparkline(
412− values: viewModel.zaiSparklineValues,
413− color: Self.usageColor(zaiFiveHourPercent ?? 0)
414− )
410+ // Peak hours notice.
411+ zaiPeakHours
412+
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+ }
415430 }
416431 }
417432
@@ -439,6 +454,42 @@ struct MenuView: View {
439454 }
440455 }
441456
457+ /// z.ai peak hours: 14:00–18:00 CST (UTC+8), i.e. 06:00–10:00 UTC.
458+ /// Limits are most likely to bind during this window. Displayed in the
459+ /// user's local timezone so they know when to expect slowdowns.
460+ private var zaiPeakHours: some View {
461+ let inPeak = ViewModel.zaiInPeakHours
462+
463+ return HStack(spacing: 6) {
464+ Circle()
465+ .fill(inPeak ? Color.orange : Color.secondary.opacity(0.4))
466+ .frame(width: 6, height: 6)
467+
468+ Text(inPeak ? "Peak hours now" : "Peak \(Self.zaiPeakHoursLocal)")
469+ .font(Self.footnoteFont)
470+ .foregroundColor(inPeak ? .orange : .secondary)
471+ }
472+ }
473+
474+ /// z.ai peak hours converted to the user's local timezone, e.g. "09:00–13:00".
475+ private static var zaiPeakHoursLocal: String {
476+ let formatter = DateFormatter()
477+ formatter.timeZone = TimeZone.current
478+ formatter.dateFormat = "HH:mm"
479+
480+ var utc = DateComponents()
481+ utc.timeZone = TimeZone(identifier: "UTC")
482+ utc.hour = 6
483+ var calendar = Calendar(identifier: .gregorian)
484+ calendar.timeZone = TimeZone(identifier: "UTC")!
485+ let start = calendar.date(from: utc)!
486+
487+ utc.hour = 10
488+ let end = calendar.date(from: utc)!
489+
490+ return "\(formatter.string(from: start))–\(formatter.string(from: end))"
491+ }
492+
442493 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
443494 private static func zaiResetsIn(from date: Date) -> String {
444495 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+49−20
@@ -7,7 +7,7 @@ import UserNotifications
77/// Posted when the user changes the refresh interval, so the app delegate can
88/// restart its timer with the new period.
99extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
1111}
1212
1313/// The direction a metric has moved over the most recent history points.
@@ -237,11 +237,13 @@ final class ViewModel: ObservableObject {
237237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238238 }
239239
240− /// The text to show in the menu bar: `⚡{balance}` (Hyper), `🕐{percent}%` (Claude
241− /// 5-hour), `📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and
242− /// `📆{percent}%` (z.ai weekly) — each omitted when absent or at 0%, and entirely
243− /// absent when the service is not configured. An unconfigured service produces no
244− /// placeholder in the menu bar.
240+ /// The text to show in the menu bar. Each configured provider contributes a
241+ /// segment prefixed with a provider emoji; segments are separated by ` · `
242+ /// (middle dot). Within a segment, windows are listed as bare percentages
243+ /// 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.
245+ ///
246+ /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
245247 ///
246248 /// Takes its values as parameters rather than reading the properties: `@Published`
247249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -253,30 +255,50 @@ final class ViewModel: ObservableObject {
253255 claudeFiveHourPercent: Int?,
254256 claudeSevenDayPercent: Int?,
255257 zaiFiveHourPercent: Int?,
256− zaiWeeklyPercent: Int?
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
257260 ) -> String {
258− var parts: [String] = []
261+ var segments: [String] = []
262+
263+ // Hyper
259264 if hyperConfigured, let balance = balance {
260− parts.append("⚡\(formatBalance(balance))")
265+ segments.append("⚡\(formatBalance(balance))")
266+ }
267+
268+ // Claude — provider emoji + bare percentages
269+ var claudeWindows: [String] = []
270+ if let fiveHour = claudeFiveHourPercent {
271+ claudeWindows.append("\(fiveHour)%")
272+ }
273+ if let sevenDay = claudeSevenDayPercent {
274+ claudeWindows.append("\(sevenDay)%")
261275 }
262− if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
263− parts.append("🕐\(fiveHour)%")
276+ if !claudeWindows.isEmpty {
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
264278 }
265− if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
266− parts.append("📅\(sevenDay)%")
279+
280+ // z.ai — provider emoji + bare percentages
281+ var zaiWindows: [String] = []
282+ if let zaiFiveHour = zaiFiveHourPercent {
283+ zaiWindows.append("\(zaiFiveHour)%")
267284 }
268− if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
269− parts.append("🤖\(zaiFiveHour)%")
285+ if let zaiWeekly = zaiWeeklyPercent {
286+ zaiWindows.append("\(zaiWeekly)%")
270287 }
271− if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
272− parts.append("📆\(zaiWeekly)%")
288+ if !zaiWindows.isEmpty {
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
290+ if zaiInPeakHours {
291+ segment += "🔶"
292+ }
293+ segments.append(segment)
273294 }
274− if parts.isEmpty {
295+
296+ if segments.isEmpty {
275297 // No service is configured or has anything to show. While loading,
276298 // show an ellipsis; otherwise the title is empty.
277299 return isLoading ? "…" : ""
278300 }
279− return parts.joined(separator: " · ")
301+ return segments.joined(separator: " · ")
280302 }
281303
282304 /// The text to show in the menu bar for the current state.
@@ -288,10 +310,17 @@ final class ViewModel: ObservableObject {
288310 claudeFiveHourPercent: claudeFiveHourPercent,
289311 claudeSevenDayPercent: claudeSevenDayPercent,
290312 zaiFiveHourPercent: zaiFiveHourPercent,
291− zaiWeeklyPercent: zaiWeeklyPercent
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
292315 )
293316 }
294317
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+
295324 /// The formatted balance string for display in the popover.
296325 var formattedBalance: String {
297326 guard let balance = balance else { return "?" }
UsageMenubarTests/StatusBarTextTests.swift+106−46
@@ -15,9 +15,10 @@ 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 )
20− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
21+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
2122 }
2223
2324 func testHyperOnly() {
@@ -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,9 +37,10 @@ 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 )
40− XCTAssertEqual(title, "🕐62% · 📅8%")
43+ XCTAssertEqual(title, "\u{2055}62% 8%")
4144 }
4245
4346 func testLoadingWithNothingToShow() {
@@ -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,9 +73,10 @@ 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 )
73− XCTAssertEqual(title, "🕐62%")
79+ XCTAssertEqual(title, "\u{2055}62%")
7480 }
7581
7682 func testLoadingKeepsAStaleBalanceOnScreen() {
@@ -78,19 +84,21 @@ 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 )
83− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
90+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
8491 }
8592
86− func testZeroPercentIsShownRatherThanOmitted() {
93+ func testZeroPercentIsShown() {
8794 let title = ViewModel.statusBarText(
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 )
93− XCTAssertEqual(title, "⚡42 · 📅8%")
101+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
94102 }
95103
96104 func testBothClaudeWindowsZero() {
@@ -98,9 +106,10 @@ 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 )
103− XCTAssertEqual(title, "⚡42")
112+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
104113 }
105114
106115 func testFiveHourOnly() {
@@ -108,9 +117,10 @@ 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 )
113− XCTAssertEqual(title, "🕐62%")
123+ XCTAssertEqual(title, "\u{2055}62%")
114124 }
115125
116126 func testSevenDayOnly() {
@@ -118,9 +128,10 @@ 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 )
123− XCTAssertEqual(title, "📅8%")
134+ XCTAssertEqual(title, "\u{2055}8%")
124135 }
125136
126137 // MARK: - Hyper not configured
@@ -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,9 +165,10 @@ 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 )
157− XCTAssertEqual(title, "🕐62% · 📅8%")
171+ XCTAssertEqual(title, "\u{2055}62% 8%")
158172 }
159173
160174 // MARK: - z.ai in Title
@@ -164,9 +178,10 @@ 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 )
169− XCTAssertEqual(title, "🤖12%")
184+ XCTAssertEqual(title, "\u{005A}12%")
170185 }
171186
172187 func testZaiWithHyper() {
@@ -174,9 +189,10 @@ 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 )
179− XCTAssertEqual(title, "⚡42 · 🤖12%")
195+ XCTAssertEqual(title, "⚡42 · \u{005A}12%")
180196 }
181197
182198 func testZaiWithClaude() {
@@ -184,9 +200,10 @@ 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 )
189− XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
206+ XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
190207 }
191208
192209 func testAllThreeServices() {
@@ -194,19 +211,21 @@ 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 )
199− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12% · 📆3%")
217+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
200218 }
201219
202− func testZaiZeroIsOmitted() {
220+ func testZaiZeroIsShown() {
203221 let title = ViewModel.statusBarText(
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 )
209− XCTAssertEqual(title, "⚡42")
228+ XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
210229 }
211230
212231 func testLoadingKeepsZaiPercentOnScreen() {
@@ -214,9 +233,10 @@ 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 )
219− XCTAssertEqual(title, "🤖12%")
239+ XCTAssertEqual(title, "\u{005A}12%")
220240 }
221241
222242 // MARK: - z.ai Weekly
@@ -226,9 +246,10 @@ 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 )
231− XCTAssertEqual(title, "📆3%")
252+ XCTAssertEqual(title, "\u{005A}3%")
232253 }
233254
234255 func testZaiWeeklyWithFiveHour() {
@@ -236,19 +257,21 @@ 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 )
241− XCTAssertEqual(title, "🤖12% · 📆3%")
263+ XCTAssertEqual(title, "\u{005A}12% 3%")
242264 }
243265
244− func testZaiWeeklyZeroIsOmitted() {
266+ func testZaiWeeklyZeroIsShown() {
245267 let title = ViewModel.statusBarText(
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 )
251− XCTAssertEqual(title, "🤖12%")
274+ XCTAssertEqual(title, "\u{005A}12% 0%")
252275 }
253276
254277 func testLoadingKeepsZaiWeeklyOnScreen() {
@@ -256,9 +279,46 @@ 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
284+ )
285+ XCTAssertEqual(title, "\u{005A}3%")
286+ }
287+
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{2055}62% 8% · \u{005A}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{005A}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
260319 )
261− XCTAssertEqual(title, "📆3%")
320+ // No z.ai segment, so peak marker has nothing to attach to.
321+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
262322 }
263323
264324 // MARK: - Claude Window Percentages
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
66options:
77 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
88 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: com.ilyakooo0
9+ bundleIdPrefix: soy.iko
1010 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
1111 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
1212 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
4747 properties:
4848 CFBundleName: UsageMenubar
4949 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: com.ilyakooo0.usage-menubar
50+ CFBundleIdentifier: soy.iko.usage-menubar
5151 # Overwritten by CI — see the note on MARKETING_VERSION above.
5252 CFBundleVersion: "1"
5353 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
5757 NSHighResolutionCapable: true
5858 settings:
5959 base:
60− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
6161 PRODUCT_NAME: UsageMenubar
6262 INFOPLIST_FILE: UsageMenubar/Info.plist
6363 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
8484 - target: UsageMenubar
8585 settings:
8686 base:
87− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
8888 PRODUCT_NAME: UsageMenubarTests
8989 BUNDLE_LOADER: "$(TEST_HOST)"
9090 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"