on master help sign in
CLAUDE.md+2−2
since splitsnapshots
unifiedsplit
@@ -20,8 +20,8 @@ click-to-copy on the headline, and threshold-crossing notifications.
20- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
20- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
21- `VersionFormatter.swift` — Version string formatting
21- `VersionFormatter.swift` — Version string formatting
22
22
23−The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
23+The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24−`📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and `📆{percent}%`
24+`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
25(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
25(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
26service is not configured (no API key or no credentials). An unconfigured service
26service is not configured (no API key or no credentials). An unconfigured service
27produces no placeholder in the menu bar. The three fetches are independent —
27produces 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
9## Features
9## Features
10
10
11- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
11- ⚡ **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
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
13+- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
14- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
14- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
15- 🖱️ **Right-click menu** — quick access to Refresh and Quit
15- 🖱️ **Right-click menu** — quick access to Refresh and Quit
16- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
16- 🔒 **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%:
24| Service | Emoji | Example |
24| Service | Emoji | Example |
25|---------|-------|---------|
25|---------|-------|---------|
26| Hyper balance | ⚡ | `⚡42` |
26| Hyper balance | ⚡ | `⚡42` |
27−| Claude 5-hour | 🕐 | `🕐62%` |
27+| Claude 5-hour | ⁕ | `⁕62%` |
28−| Claude 7-day | 📅 | `📅8%` |
28+| Claude 7-day | ⁕ | `⁕8%` |
29−| z.ai 5-hour | 🤖 | `🤖12%` |
29+| z.ai 5-hour | Z | `Z12%` |
30−| z.ai weekly | 📆 | `📆3%` |
30+| z.ai weekly | Z | `Z3%` |
31
31
32Unconfigured services don't appear at all — no placeholder, no icon.
32Unconfigured services don't appear at all — no placeholder, no icon.
33
33
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
7 <key>CFBundleDisplayName</key>
7 <key>CFBundleDisplayName</key>
8 <string>Usage Menubar</string>
8 <string>Usage Menubar</string>
9 <key>CFBundleIdentifier</key>
9 <key>CFBundleIdentifier</key>
10− <string>com.ilyakooo0.usage-menubar</string>
10+ <string>soy.iko.usage-menubar</string>
11 <key>CFBundleVersion</key>
11 <key>CFBundleVersion</key>
12 <string>1</string>
12 <string>1</string>
13 <key>CFBundleShortVersionString</key>
13 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
82
82
83/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
83/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
84enum KeychainHelper {
84enum KeychainHelper {
85− private static let service = "com.ilyakooo0.usage-menubar"
85+ private static let service = "soy.iko.usage-menubar"
86 private static let account = "hyper-api-key"
86 private static let account = "hyper-api-key"
87
87
88 private static let store = KeychainStore(service: service, account: account)
88 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112/// Uses the same keychain service as the Hyper key but a different account, so the
112/// Uses the same keychain service as the Hyper key but a different account, so the
113/// two keys coexist without clobbering each other.
113/// two keys coexist without clobbering each other.
114enum ZaiKeychainHelper {
114enum ZaiKeychainHelper {
115− private static let service = "com.ilyakooo0.usage-menubar"
115+ private static let service = "soy.iko.usage-menubar"
116 private static let account = "zai-api-key"
116 private static let account = "zai-api-key"
117
117
118 private static let store = KeychainStore(service: service, account: account)
118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+58−7
@@ -259,7 +259,7 @@ struct MenuView: View {
259 VStack(spacing: 8) {
259 VStack(spacing: 8) {
260 HStack(alignment: .firstTextBaseline, spacing: 8) {
260 HStack(alignment: .firstTextBaseline, spacing: 8) {
261 headlineNumber(
261 headlineNumber(
262− text: "🕐 \(percent)%",
262+ text: "⁕ \\(percent)%",
263 color: Self.usageColor(percent),
263 color: Self.usageColor(percent),
264 trend: viewModel.claudeTrend,
264 trend: viewModel.claudeTrend,
265 copiedConfirmation: viewModel.copiedClaudeConfirmation,
265 copiedConfirmation: viewModel.copiedClaudeConfirmation,
@@ -379,7 +379,7 @@ struct MenuView: View {
379 VStack(spacing: 8) {
379 VStack(spacing: 8) {
380 HStack(alignment: .firstTextBaseline, spacing: 8) {
380 HStack(alignment: .firstTextBaseline, spacing: 8) {
381 headlineNumber(
381 headlineNumber(
382− text: "🤖 \(fiveHour)%",
382+ text: "Z \\(fiveHour)%",
383 color: Self.usageColor(fiveHour),
383 color: Self.usageColor(fiveHour),
384 trend: viewModel.zaiTrend,
384 trend: viewModel.zaiTrend,
385 copiedConfirmation: viewModel.copiedZaiConfirmation,
385 copiedConfirmation: viewModel.copiedZaiConfirmation,
@@ -407,11 +407,26 @@ struct MenuView: View {
407 }
407 }
408 }
408 }
409
409
410+ // Peak hours notice.
411+ zaiPeakHours
412+
410− // Sparkline for the z.ai 5-hour % history.
413+ // Sparkline for the z.ai 5-hour % history, with an orange tint
411− sparkline(
414+ // when currently in peak hours.
415+ if viewModel.zaiSparklineValues.count >= 2 {
416+ Sparkline(
412− values: viewModel.zaiSparklineValues,
417+ values: viewModel.zaiSparklineValues,
413− color: Self.usageColor(zaiFiveHourPercent ?? 0)
418+ color: Self.usageColor(zaiFiveHourPercent ?? 0)
414− )
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+ }
415 }
430 }
416 }
431 }
417
432
@@ -439,6 +454,42 @@ struct MenuView: View {
439 }
454 }
440 }
455 }
441
456
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+
442 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
493 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
443 private static func zaiResetsIn(from date: Date) -> String {
494 private static func zaiResetsIn(from date: Date) -> String {
444 let interval = date.timeIntervalSince(Date())
495 let interval = date.timeIntervalSince(Date())
UsageMenubar/UsageMenubarApp.swift+2−1
@@ -89,7 +89,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
89 claudeFiveHourPercent: fiveHour,
89 claudeFiveHourPercent: fiveHour,
90 claudeSevenDayPercent: sevenDay,
90 claudeSevenDayPercent: sevenDay,
91 zaiFiveHourPercent: zaiFiveHour,
91 zaiFiveHourPercent: zaiFiveHour,
92− zaiWeeklyPercent: zaiWeekly
92+ zaiWeeklyPercent: zaiWeekly,
93+ zaiInPeakHours: ViewModel.zaiInPeakHours
93 )
94 )
94 }
95 }
95 .removeDuplicates()
96 .removeDuplicates()
UsageMenubar/ViewModel.swift+49−20
@@ -7,7 +7,7 @@ import UserNotifications
7/// Posted when the user changes the refresh interval, so the app delegate can
7/// Posted when the user changes the refresh interval, so the app delegate can
8/// restart its timer with the new period.
8/// restart its timer with the new period.
9extension Notification.Name {
9extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
11}
11}
12
12
13/// The direction a metric has moved over the most recent history points.
13/// The direction a metric has moved over the most recent history points.
@@ -237,11 +237,13 @@ final class ViewModel: ObservableObject {
237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238 }
238 }
239
239
240− /// The text to show in the menu bar: `⚡{balance}` (Hyper), `🕐{percent}%` (Claude
240+ /// The text to show in the menu bar. Each configured provider contributes a
241− /// 5-hour), `📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and
241+ /// segment prefixed with a provider emoji; segments are separated by ` · `
242− /// `📆{percent}%` (z.ai weekly) — each omitted when absent or at 0%, and entirely
242+ /// (middle dot). Within a segment, windows are listed as bare percentages
243− /// absent when the service is not configured. An unconfigured service produces no
243+ /// separated by spaces. An unconfigured service produces no segment.
244− /// placeholder in the menu bar.
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%🔶`
245 ///
247 ///
246 /// Takes its values as parameters rather than reading the properties: `@Published`
248 /// Takes its values as parameters rather than reading the properties: `@Published`
247 /// publishes in `willSet`, so a Combine subscriber that called back into the view
249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -253,30 +255,50 @@ final class ViewModel: ObservableObject {
253 claudeFiveHourPercent: Int?,
255 claudeFiveHourPercent: Int?,
254 claudeSevenDayPercent: Int?,
256 claudeSevenDayPercent: Int?,
255 zaiFiveHourPercent: Int?,
257 zaiFiveHourPercent: Int?,
256− zaiWeeklyPercent: Int?
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
257 ) -> String {
260 ) -> String {
258− var parts: [String] = []
261+ var segments: [String] = []
262+
263+ // Hyper
259 if hyperConfigured, let balance = balance {
264 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)%")
261 }
275 }
262− if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
276+ if !claudeWindows.isEmpty {
263− parts.append("🕐\(fiveHour)%")
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
264 }
278 }
265− if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
279+
266− parts.append("📅\(sevenDay)%")
280+ // z.ai — provider emoji + bare percentages
281+ var zaiWindows: [String] = []
282+ if let zaiFiveHour = zaiFiveHourPercent {
283+ zaiWindows.append("\(zaiFiveHour)%")
267 }
284 }
268− if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
285+ if let zaiWeekly = zaiWeeklyPercent {
269− parts.append("🤖\(zaiFiveHour)%")
286+ zaiWindows.append("\(zaiWeekly)%")
270 }
287 }
271− if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
288+ if !zaiWindows.isEmpty {
272− parts.append("📆\(zaiWeekly)%")
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
290+ if zaiInPeakHours {
291+ segment += "🔶"
292+ }
293+ segments.append(segment)
273 }
294 }
295+
274− if parts.isEmpty {
296+ if segments.isEmpty {
275 // No service is configured or has anything to show. While loading,
297 // No service is configured or has anything to show. While loading,
276 // show an ellipsis; otherwise the title is empty.
298 // show an ellipsis; otherwise the title is empty.
277 return isLoading ? "…" : ""
299 return isLoading ? "…" : ""
278 }
300 }
279− return parts.joined(separator: " · ")
301+ return segments.joined(separator: " · ")
280 }
302 }
281
303
282 /// The text to show in the menu bar for the current state.
304 /// The text to show in the menu bar for the current state.
@@ -288,10 +310,17 @@ final class ViewModel: ObservableObject {
288 claudeFiveHourPercent: claudeFiveHourPercent,
310 claudeFiveHourPercent: claudeFiveHourPercent,
289 claudeSevenDayPercent: claudeSevenDayPercent,
311 claudeSevenDayPercent: claudeSevenDayPercent,
290 zaiFiveHourPercent: zaiFiveHourPercent,
312 zaiFiveHourPercent: zaiFiveHourPercent,
291− zaiWeeklyPercent: zaiWeeklyPercent
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
292 )
315 )
293 }
316 }
294
317
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+
295 /// The formatted balance string for display in the popover.
324 /// The formatted balance string for display in the popover.
296 var formattedBalance: String {
325 var formattedBalance: String {
297 guard let balance = balance else { return "?" }
326 guard let balance = balance else { return "?" }
UsageMenubarTests/StatusBarTextTests.swift+106−46
@@ -15,9 +15,10 @@ final class StatusBarTextTests: XCTestCase {
15 balance: 42, isLoading: false,
15 balance: 42, isLoading: false,
16 hyperConfigured: true,
16 hyperConfigured: true,
17 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
17 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19+ zaiInPeakHours: false
19 )
20 )
20− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
21+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
21 }
22 }
22
23
23 func testHyperOnly() {
24 func testHyperOnly() {
@@ -25,7 +26,8 @@ final class StatusBarTextTests: XCTestCase {
25 balance: 42, isLoading: false,
26 balance: 42, isLoading: false,
26 hyperConfigured: true,
27 hyperConfigured: true,
27 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
28 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
28− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
29+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30+ zaiInPeakHours: false
29 )
31 )
30 XCTAssertEqual(title, "⚡42")
32 XCTAssertEqual(title, "⚡42")
31 }
33 }
@@ -35,9 +37,10 @@ final class StatusBarTextTests: XCTestCase {
35 balance: nil, isLoading: false,
37 balance: nil, isLoading: false,
36 hyperConfigured: false,
38 hyperConfigured: false,
37 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
39 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
38− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
40+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41+ zaiInPeakHours: false
39 )
42 )
40− XCTAssertEqual(title, "🕐62% · 📅8%")
43+ XCTAssertEqual(title, "\u{2055}62% 8%")
41 }
44 }
42
45
43 func testLoadingWithNothingToShow() {
46 func testLoadingWithNothingToShow() {
@@ -45,7 +48,8 @@ final class StatusBarTextTests: XCTestCase {
45 balance: nil, isLoading: true,
48 balance: nil, isLoading: true,
46 hyperConfigured: false,
49 hyperConfigured: false,
47 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
50 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
48− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
51+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52+ zaiInPeakHours: false
49 )
53 )
50 XCTAssertEqual(title, "…")
54 XCTAssertEqual(title, "…")
51 }
55 }
@@ -55,7 +59,8 @@ final class StatusBarTextTests: XCTestCase {
55 balance: nil, isLoading: false,
59 balance: nil, isLoading: false,
56 hyperConfigured: false,
60 hyperConfigured: false,
57 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
61 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
58− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
62+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63+ zaiInPeakHours: false
59 )
64 )
60 XCTAssertEqual(title, "")
65 XCTAssertEqual(title, "")
61 }
66 }
@@ -68,9 +73,10 @@ final class StatusBarTextTests: XCTestCase {
68 balance: nil, isLoading: true,
73 balance: nil, isLoading: true,
69 hyperConfigured: false,
74 hyperConfigured: false,
70 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
75 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
71− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
76+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77+ zaiInPeakHours: false
72 )
78 )
73− XCTAssertEqual(title, "🕐62%")
79+ XCTAssertEqual(title, "\u{2055}62%")
74 }
80 }
75
81
76 func testLoadingKeepsAStaleBalanceOnScreen() {
82 func testLoadingKeepsAStaleBalanceOnScreen() {
@@ -78,19 +84,21 @@ final class StatusBarTextTests: XCTestCase {
78 balance: 42, isLoading: true,
84 balance: 42, isLoading: true,
79 hyperConfigured: true,
85 hyperConfigured: true,
80 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
86 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
81− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
87+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88+ zaiInPeakHours: false
82 )
89 )
83− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
90+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
84 }
91 }
85
92
86− func testZeroPercentIsShownRatherThanOmitted() {
93+ func testZeroPercentIsShown() {
87 let title = ViewModel.statusBarText(
94 let title = ViewModel.statusBarText(
88 balance: 42, isLoading: false,
95 balance: 42, isLoading: false,
89 hyperConfigured: true,
96 hyperConfigured: true,
90 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
97 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
91− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
98+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99+ zaiInPeakHours: false
92 )
100 )
93− XCTAssertEqual(title, "⚡42 · 📅8%")
101+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
94 }
102 }
95
103
96 func testBothClaudeWindowsZero() {
104 func testBothClaudeWindowsZero() {
@@ -98,9 +106,10 @@ final class StatusBarTextTests: XCTestCase {
98 balance: 42, isLoading: false,
106 balance: 42, isLoading: false,
99 hyperConfigured: true,
107 hyperConfigured: true,
100 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
108 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
101− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
109+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110+ zaiInPeakHours: false
102 )
111 )
103− XCTAssertEqual(title, "⚡42")
112+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
104 }
113 }
105
114
106 func testFiveHourOnly() {
115 func testFiveHourOnly() {
@@ -108,9 +117,10 @@ final class StatusBarTextTests: XCTestCase {
108 balance: nil, isLoading: false,
117 balance: nil, isLoading: false,
109 hyperConfigured: false,
118 hyperConfigured: false,
110 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
119 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
111− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
120+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121+ zaiInPeakHours: false
112 )
122 )
113− XCTAssertEqual(title, "🕐62%")
123+ XCTAssertEqual(title, "\u{2055}62%")
114 }
124 }
115
125
116 func testSevenDayOnly() {
126 func testSevenDayOnly() {
@@ -118,9 +128,10 @@ final class StatusBarTextTests: XCTestCase {
118 balance: nil, isLoading: false,
128 balance: nil, isLoading: false,
119 hyperConfigured: false,
129 hyperConfigured: false,
120 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
130 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
121− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
131+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132+ zaiInPeakHours: false
122 )
133 )
123− XCTAssertEqual(title, "📅8%")
134+ XCTAssertEqual(title, "\u{2055}8%")
124 }
135 }
125
136
126 // MARK: - Hyper not configured
137 // MARK: - Hyper not configured
@@ -132,7 +143,8 @@ final class StatusBarTextTests: XCTestCase {
132 balance: 42, isLoading: false,
143 balance: 42, isLoading: false,
133 hyperConfigured: false,
144 hyperConfigured: false,
134 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
145 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
135− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
146+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147+ zaiInPeakHours: false
136 )
148 )
137 XCTAssertEqual(title, "")
149 XCTAssertEqual(title, "")
138 }
150 }
@@ -142,7 +154,8 @@ final class StatusBarTextTests: XCTestCase {
142 balance: nil, isLoading: true,
154 balance: nil, isLoading: true,
143 hyperConfigured: false,
155 hyperConfigured: false,
144 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
156 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
145− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
157+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158+ zaiInPeakHours: false
146 )
159 )
147 XCTAssertEqual(title, "…")
160 XCTAssertEqual(title, "…")
148 }
161 }
@@ -152,9 +165,10 @@ final class StatusBarTextTests: XCTestCase {
152 balance: nil, isLoading: false,
165 balance: nil, isLoading: false,
153 hyperConfigured: false,
166 hyperConfigured: false,
154 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
167 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
155− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
168+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169+ zaiInPeakHours: false
156 )
170 )
157− XCTAssertEqual(title, "🕐62% · 📅8%")
171+ XCTAssertEqual(title, "\u{2055}62% 8%")
158 }
172 }
159
173
160 // MARK: - z.ai in Title
174 // MARK: - z.ai in Title
@@ -164,9 +178,10 @@ final class StatusBarTextTests: XCTestCase {
164 balance: nil, isLoading: false,
178 balance: nil, isLoading: false,
165 hyperConfigured: false,
179 hyperConfigured: false,
166 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
180 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
167− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
181+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182+ zaiInPeakHours: false
168 )
183 )
169− XCTAssertEqual(title, "🤖12%")
184+ XCTAssertEqual(title, "\u{005A}12%")
170 }
185 }
171
186
172 func testZaiWithHyper() {
187 func testZaiWithHyper() {
@@ -174,9 +189,10 @@ final class StatusBarTextTests: XCTestCase {
174 balance: 42, isLoading: false,
189 balance: 42, isLoading: false,
175 hyperConfigured: true,
190 hyperConfigured: true,
176 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
191 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
177− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
192+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193+ zaiInPeakHours: false
178 )
194 )
179− XCTAssertEqual(title, "⚡42 · 🤖12%")
195+ XCTAssertEqual(title, "⚡42 · \u{005A}12%")
180 }
196 }
181
197
182 func testZaiWithClaude() {
198 func testZaiWithClaude() {
@@ -184,9 +200,10 @@ final class StatusBarTextTests: XCTestCase {
184 balance: nil, isLoading: false,
200 balance: nil, isLoading: false,
185 hyperConfigured: false,
201 hyperConfigured: false,
186 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
202 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
187− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
203+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204+ zaiInPeakHours: false
188 )
205 )
189− XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
206+ XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
190 }
207 }
191
208
192 func testAllThreeServices() {
209 func testAllThreeServices() {
@@ -194,19 +211,21 @@ final class StatusBarTextTests: XCTestCase {
194 balance: 42, isLoading: false,
211 balance: 42, isLoading: false,
195 hyperConfigured: true,
212 hyperConfigured: true,
196 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
213 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
197− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
214+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215+ zaiInPeakHours: false
198 )
216 )
199− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12% · 📆3%")
217+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
200 }
218 }
201
219
202− func testZaiZeroIsOmitted() {
220+ func testZaiZeroIsShown() {
203 let title = ViewModel.statusBarText(
221 let title = ViewModel.statusBarText(
204 balance: 42, isLoading: false,
222 balance: 42, isLoading: false,
205 hyperConfigured: true,
223 hyperConfigured: true,
206 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
224 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
207− zaiFiveHourPercent: 0, zaiWeeklyPercent: 0
225+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226+ zaiInPeakHours: false
208 )
227 )
209− XCTAssertEqual(title, "⚡42")
228+ XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
210 }
229 }
211
230
212 func testLoadingKeepsZaiPercentOnScreen() {
231 func testLoadingKeepsZaiPercentOnScreen() {
@@ -214,9 +233,10 @@ final class StatusBarTextTests: XCTestCase {
214 balance: nil, isLoading: true,
233 balance: nil, isLoading: true,
215 hyperConfigured: false,
234 hyperConfigured: false,
216 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
235 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
217− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
236+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237+ zaiInPeakHours: false
218 )
238 )
219− XCTAssertEqual(title, "🤖12%")
239+ XCTAssertEqual(title, "\u{005A}12%")
220 }
240 }
221
241
222 // MARK: - z.ai Weekly
242 // MARK: - z.ai Weekly
@@ -226,9 +246,10 @@ final class StatusBarTextTests: XCTestCase {
226 balance: nil, isLoading: false,
246 balance: nil, isLoading: false,
227 hyperConfigured: false,
247 hyperConfigured: false,
228 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
229− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
249+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250+ zaiInPeakHours: false
230 )
251 )
231− XCTAssertEqual(title, "📆3%")
252+ XCTAssertEqual(title, "\u{005A}3%")
232 }
253 }
233
254
234 func testZaiWeeklyWithFiveHour() {
255 func testZaiWeeklyWithFiveHour() {
@@ -236,19 +257,21 @@ final class StatusBarTextTests: XCTestCase {
236 balance: nil, isLoading: false,
257 balance: nil, isLoading: false,
237 hyperConfigured: false,
258 hyperConfigured: false,
238 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
259 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
239− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
260+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261+ zaiInPeakHours: false
240 )
262 )
241− XCTAssertEqual(title, "🤖12% · 📆3%")
263+ XCTAssertEqual(title, "\u{005A}12% 3%")
242 }
264 }
243
265
244− func testZaiWeeklyZeroIsOmitted() {
266+ func testZaiWeeklyZeroIsShown() {
245 let title = ViewModel.statusBarText(
267 let title = ViewModel.statusBarText(
246 balance: nil, isLoading: false,
268 balance: nil, isLoading: false,
247 hyperConfigured: false,
269 hyperConfigured: false,
248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
270 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249− zaiFiveHourPercent: 12, zaiWeeklyPercent: 0
271+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272+ zaiInPeakHours: false
250 )
273 )
251− XCTAssertEqual(title, "🤖12%")
274+ XCTAssertEqual(title, "\u{005A}12% 0%")
252 }
275 }
253
276
254 func testLoadingKeepsZaiWeeklyOnScreen() {
277 func testLoadingKeepsZaiWeeklyOnScreen() {
@@ -256,9 +279,46 @@ final class StatusBarTextTests: XCTestCase {
256 balance: nil, isLoading: true,
279 balance: nil, isLoading: true,
257 hyperConfigured: false,
280 hyperConfigured: false,
258 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
281 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
260 )
319 )
320+ // No z.ai segment, so peak marker has nothing to attach to.
261− XCTAssertEqual(title, "📆3%")
321+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
262 }
322 }
263
323
264 // MARK: - Claude Window Percentages
324 // MARK: - Claude Window Percentages
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
6options:
6options:
7 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
7 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
8 # The app target pins its own id explicitly below, so this is just a default.
8 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: com.ilyakooo0
9+ bundleIdPrefix: soy.iko
10 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
10 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
11 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
11 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
12 deploymentTarget:
12 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
47 properties:
47 properties:
48 CFBundleName: UsageMenubar
48 CFBundleName: UsageMenubar
49 CFBundleDisplayName: Usage Menubar
49 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: com.ilyakooo0.usage-menubar
50+ CFBundleIdentifier: soy.iko.usage-menubar
51 # Overwritten by CI — see the note on MARKETING_VERSION above.
51 # Overwritten by CI — see the note on MARKETING_VERSION above.
52 CFBundleVersion: "1"
52 CFBundleVersion: "1"
53 CFBundleShortVersionString: "1.0.0"
53 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
57 NSHighResolutionCapable: true
57 NSHighResolutionCapable: true
58 settings:
58 settings:
59 base:
59 base:
60− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
61 PRODUCT_NAME: UsageMenubar
61 PRODUCT_NAME: UsageMenubar
62 INFOPLIST_FILE: UsageMenubar/Info.plist
62 INFOPLIST_FILE: UsageMenubar/Info.plist
63 # Grants the outgoing-network-connection entitlement needed to reach
63 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
84 - target: UsageMenubar
84 - target: UsageMenubar
85 settings:
85 settings:
86 base:
86 base:
87− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
88 PRODUCT_NAME: UsageMenubarTests
88 PRODUCT_NAME: UsageMenubarTests
89 BUNDLE_LOADER: "$(TEST_HOST)"
89 BUNDLE_LOADER: "$(TEST_HOST)"
90 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"
90 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"