on master help sign in
CLAUDE.md+35−10
since splitsnapshots
unifiedsplit
@@ -1,26 +1,51 @@
1# UsageMenubar
1# UsageMenubar
2
2
3## Architecture
3## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
4+macOS menu bar app (SwiftUI, macOS 14+) showing three LLM providers with equal
5+visual treatment:
6+- **Hyper** (Charm) — credit balance
5−Claude Code's Pro/Max usage limits when the CLI is signed in on the machine, plus
7+- **Claude Code** — Pro/Max usage limits (5-hour, 7-day, 7-day Opus)
6−z.ai Coding Plan usage limits when an API key is entered in settings.
8+- **z.ai Coding Plan** — usage limits (5-hour, weekly)
9+
10+Each configured provider gets the same popover treatment: a 26pt subhero headline
11+number, bars under every window, a sparkline of recent history, a trend arrow,
12+click-to-copy on the headline, and threshold-crossing notifications.
13+
7- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
14- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
8−- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, history
15+- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, per-provider history
9−- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, z.ai limits, settings)
16+- `MenuView.swift` — SwiftUI popover view (3 equal provider sections, settings)
10- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
17- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
11- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
18- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
12- `ZaiUsageClient.swift` — API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
19- `ZaiUsageClient.swift` — API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
13- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
20- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
14- `VersionFormatter.swift` — Version string formatting
21- `VersionFormatter.swift` — Version string formatting
15
22
16−The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
23+The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
17−`📅{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}%`
18(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
19service 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
20produces no placeholder in the menu bar. The three fetches are independent —
27produces no placeholder in the menu bar. The three fetches are independent —
21`ViewModel.refresh()` runs them with `async let`, and any can fail or be
28`ViewModel.refresh()` runs them with `async let`, and any can fail or be
22unconfigured without touching the others.
29unconfigured without touching the others.
23
30
31+## Equal provider treatment
32+All three providers share the same visual structure in the popover:
33+1. Section header with icon + plan label
34+2. Headline number (26pt subheroFont) with emoji, color-coded, click-to-copy, trend arrow
35+3. Reset countdown text (Claude/z.ai windows only — Hyper has no windows)
36+4. Bars under EVERY window (5-hour, 7-day, weekly — all get bars)
37+5. Sparkline (each provider tracks its own history via `MetricPoint` arrays)
38+6. Error row if applicable
39+
40+Notifications fire on threshold crossings:
41+- Hyper: balance drops below 10 credits
42+- Claude: 5-hour % crosses above 90%
43+- z.ai: 5-hour % crosses above 90%
44+
45+If a provider isn't configured, its section simply doesn't appear — no placeholder,
46+no error. This is already the case for Claude and z.ai; Hyper now behaves the same
47+way (gated on `hyperConfigured`).
48+
24## Claude credentials (strictly read-only)
49## Claude credentials (strictly read-only)
25`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
50`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
26Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
51Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
@@ -50,9 +75,9 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
50- Swift 5.9, macOS 14.0 deployment target
75- Swift 5.9, macOS 14.0 deployment target
51
76
52## Conventions
77## Conventions
53−- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
78+- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
54- No card-heavy/multi-material/gradient-rich designs
79- No card-heavy/multi-material/gradient-rich designs
55−- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
80+- Rounded font family at one scale (subhero/section/control/caption/footnote)
56- Monospaced digits for numbers that change
81- Monospaced digits for numbers that change
57- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
82- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
58 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
83 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -60,4 +85,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
60
85
61## VCS
86## VCS
62- jj colocated with git
87- jj colocated with git
63−- Push directly to master — no PRs for this repo
88+- Push directly to master — no PRs for this repo
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+225−172
@@ -4,21 +4,15 @@ import SwiftUI
4struct MenuView: View {
4struct MenuView: View {
5 @ObservedObject var viewModel: ViewModel
5 @ObservedObject var viewModel: ViewModel
6
6
7− /// Dims the balance while the pointer is over it, hinting that it is clickable.
8− @State private var isHoveringBalance = false
9−
10 private static let websiteURL = URL(string: "https://hyper.charm.land")!
7 private static let websiteURL = URL(string: "https://hyper.charm.land")!
11 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
8 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
12
9
13 // MARK: - Type Scale
10 // MARK: - Type Scale
14
11
15− /// One rounded family, sized so that the balance is the only large element and
12+ /// One rounded family, sized so that every provider's headline number reads at the
16− /// everything else steps down from it — nothing competes with it for attention.
13+ /// same weight. With three providers in the popover, the 26pt subhero is the right
17− /// The Claude percentage is the one exception, and it is deliberately set well
14+ /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
18− /// below the balance so it reads as the second thing on the page, not the first.
19− /// Monospaced digits keep numbers from jittering as their digits change.
15+ /// numbers from jittering as their digits change.
20− private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
21− .monospacedDigit()
22 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
16 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
23 .monospacedDigit()
17 .monospacedDigit()
24 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
18 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -28,7 +22,9 @@ struct MenuView: View {
28
22
29 var body: some View {
23 var body: some View {
30 VStack(alignment: .leading, spacing: 18) {
24 VStack(alignment: .leading, spacing: 18) {
25+ if viewModel.hyperConfigured {
31− hyper
26+ hyper
27+ }
32 if showsClaude {
28 if showsClaude {
33 serviceBreak
29 serviceBreak
34 claude
30 claude
@@ -51,10 +47,14 @@ struct MenuView: View {
51 .background(Color(nsColor: .windowBackgroundColor))
47 .background(Color(nsColor: .windowBackgroundColor))
52 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
48 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
53 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
49 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
50+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedClaudeConfirmation)
51+ .animation(.easeInOut(duration: 0.2), value: viewModel.copiedZaiConfirmation)
54 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
55 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
56 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
54 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
57− .animation(.easeInOut(duration: 0.3), value: historyValues.count)
55+ .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56+ .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57+ .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,15 +66,14 @@ struct MenuView: View {
66 Divider().opacity(0.3)
66 Divider().opacity(0.3)
67 }
67 }
68
68
69− /// The rule between the two services. Wider than the others on purpose: Hyper and
69+ /// The rule between services. Wider than the others on purpose: the providers are
70− /// Claude are two unrelated accounts, and the gap should say so before the headers do.
70+ /// unrelated accounts, and the gap should say so before the headers do.
71 private var serviceBreak: some View {
71 private var serviceBreak: some View {
72 hairline.padding(.vertical, 5)
72 hairline.padding(.vertical, 5)
73 }
73 }
74
74
75 /// A section header: an icon and a title, sized to match the "API Key" header below
75 /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three sections rather than a hero with two appendices.
76+ /// so the popover reads as three equal sections rather than a hero with appendages.
77− /// The icon is what tells the two services apart at a glance.
78 private func sectionHeader(_ title: String, systemImage: String) -> some View {
77 private func sectionHeader(_ title: String, systemImage: String) -> some View {
79 HStack(spacing: 5) {
78 HStack(spacing: 5) {
80 Image(systemName: systemImage)
79 Image(systemName: systemImage)
@@ -85,113 +84,62 @@ struct MenuView: View {
85 }
84 }
86 }
85 }
87
86
88− // MARK: - Hyper
87+ /// A reusable headline number row: the provider's main metric at subhero scale,
89−
88+ /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
90− /// The Hyper section: its header, then the balance as the popover's hero.
89+ /// copy-confirmation slot so the layout doesn't shift.
90+ private func headlineNumber(
91+ text: String,
92+ color: Color,
93+ trend: MetricTrend,
94+ copiedConfirmation: Bool,
95+ captionText: String,
96+ copyAction: @escaping () -> Void
91− private var hyper: some View {
97+ ) -> some View {
92− VStack(alignment: .leading, spacing: 12) {
98+ VStack(spacing: 6) {
93− sectionHeader("Hyper Credits", systemImage: "bolt.fill")
99+ Button(action: copyAction) {
94−
100+ HStack(alignment: .firstTextBaseline, spacing: 6) {
95− hero
96− }
97− }
98−
99− private var hero: some View {
100− VStack(spacing: 8) {
101− if viewModel.isLoading && viewModel.balance == nil {
102− PulsingBolt()
103− .transition(.opacity)
104− } else if viewModel.balance == nil {
105− VStack(spacing: 6) {
106− Text("⚡?")
101+ Text(text)
107− .font(Self.heroFont)
102+ .font(Self.subheroFont)
108− .foregroundColor(.secondary)
103+ .foregroundColor(color)
104+ .contentTransition(.opacity)
109
105
110− Text("Enter your API key below")
106+ if let symbol = trend.symbolName {
111− .font(Self.captionFont)
107+ Image(systemName: symbol)
108+ .font(.system(size: 12, weight: .semibold))
112− .foregroundColor(.secondary)
109+ .foregroundColor(.secondary)
113− }
114− .transition(.opacity)
110+ .transition(.opacity)
115− } else {
111+ }
116− VStack(spacing: 6) {
117− balanceNumber
118− caption
119− sparkline
120 }
112 }
121− .transition(.opacity)
113+ .contentShape(Rectangle())
122 }
114 }
123−
115+ .buttonStyle(.plain)
124− if let lastUpdatedText = viewModel.lastUpdatedText {
116+ .help("Click to copy")
125− Text(lastUpdatedText)
117+
126− .font(Self.footnoteFont)
118+ // Caption doubles as copy confirmation so the layout doesn't shift.
127− .foregroundColor(.secondary)
119+ Group {
128− .transition(.opacity)
120+ if copiedConfirmation {
129− }
121+ Text("✓ Copied")
130−
122+ .foregroundColor(.green)
131− if let error = viewModel.errorMessage {
123+ } else {
132− errorRow(error)
124+ Text(captionText)
133− .transition(.opacity)
134− }
135− }
136− .frame(maxWidth: .infinity)
137− }
138−
139− /// The balance itself. Clicking it copies the raw number to the clipboard.
140− private var balanceNumber: some View {
141− Button(action: { viewModel.copyBalanceToClipboard() }) {
142− HStack(alignment: .firstTextBaseline, spacing: 6) {
143− Text(viewModel.formattedBalance)
144− .font(Self.heroFont)
145− .foregroundColor(viewModel.balanceColor)
146− .contentTransition(.opacity)
147−
148− if let symbol = viewModel.balanceTrend.symbolName {
149− Image(systemName: symbol)
150− .font(.system(size: 12, weight: .semibold))
151 .foregroundColor(.secondary)
125 .foregroundColor(.secondary)
152− .transition(.opacity)
153 }
126 }
154 }
127 }
155− // Without this the hover area is only the glyphs themselves.
128+ .font(Self.captionFont)
156− .contentShape(Rectangle())
157− }
158− .buttonStyle(.plain)
159− .disabled(viewModel.balance == nil)
160− .help("Click to copy")
161− .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
162− .onHover { isHoveringBalance = $0 }
163− .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
164− }
165−
166− /// Doubles as the copy confirmation so the layout doesn't shift.
167− private var caption: some View {
168− Group {
169− if viewModel.copiedConfirmation {
170− Text("✓ Copied")
171− .foregroundColor(.green)
172− } else {
173− Text("credits")
174− .foregroundColor(.secondary)
175− }
176 }
129 }
177− .font(Self.captionFont)
178 }
130 }
179
131
132+ /// A sparkline shown when there are at least 2 history points.
180 @ViewBuilder
133 @ViewBuilder
181− private var sparkline: some View {
134+ private func sparkline(values: [Int], color: Color) -> some View {
182− if historyValues.count >= 2 {
135+ if values.count >= 2 {
183− Sparkline(values: historyValues, color: viewModel.balanceColor)
136+ Sparkline(values: values, color: color)
184 .frame(height: 26)
137 .frame(height: 26)
185 .padding(.top, 4)
138 .padding(.top, 4)
186 .transition(.opacity)
139 .transition(.opacity)
187 }
140 }
188 }
141 }
189
142
190− /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
191− private var historyValues: [Int] {
192− viewModel.balanceHistory.map { $0.balance }
193− }
194−
195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
196 /// message, and a tinted rounded background with no border.
144 /// message, and a tinted rounded background with no border.
197 private func errorRow(_ message: String) -> some View {
145 private func errorRow(_ message: String) -> some View {
@@ -216,6 +164,56 @@ struct MenuView: View {
216 .padding(.top, 2)
164 .padding(.top, 2)
217 }
165 }
218
166
167+ // MARK: - Hyper
168+
169+ /// Whether there is anything to say about Hyper. Nothing at all when Hyper isn't
170+ /// configured — the section doesn't appear, same as Claude and z.ai.
171+ private var showsHyper: Bool {
172+ viewModel.hyperConfigured
173+ }
174+
175+ /// The Hyper section: header, balance as a subhero number, caption, sparkline.
176+ private var hyper: some View {
177+ VStack(alignment: .leading, spacing: 12) {
178+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
179+
180+ if viewModel.isLoading && viewModel.balance == nil {
181+ Text("…")
182+ .font(Self.subheroFont)
183+ .foregroundColor(.secondary)
184+ } else if let balance = viewModel.balance {
185+ VStack(spacing: 8) {
186+ headlineNumber(
187+ text: "⚡ \(viewModel.formattedBalance)",
188+ color: viewModel.balanceColor,
189+ trend: viewModel.balanceTrend,
190+ copiedConfirmation: viewModel.copiedConfirmation,
191+ captionText: "credits",
192+ copyAction: { viewModel.copyBalanceToClipboard() }
193+ )
194+
195+ sparkline(
196+ values: viewModel.balanceSparklineValues,
197+ color: viewModel.balanceColor
198+ )
199+ }
200+ .transition(.opacity)
201+ }
202+
203+ if let lastUpdatedText = viewModel.lastUpdatedText {
204+ Text(lastUpdatedText)
205+ .font(Self.footnoteFont)
206+ .foregroundColor(.secondary)
207+ .transition(.opacity)
208+ }
209+
210+ if let error = viewModel.errorMessage {
211+ errorRow(error)
212+ .transition(.opacity)
213+ }
214+ }
215+ }
216+
219 // MARK: - Claude
217 // MARK: - Claude
220
218
221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -227,8 +225,8 @@ struct MenuView: View {
227 return false
225 return false
228 }
226 }
229
227
230− /// Claude Code's plan limits. The percentage leads, as the balance does above it,
228+ /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
231− /// with the individual windows underneath as quiet supporting detail.
229+ /// headline number, bars under every window, a sparkline, and error row.
232 private var claude: some View {
230 private var claude: some View {
233 VStack(alignment: .leading, spacing: 12) {
231 VStack(alignment: .leading, spacing: 12) {
234 HStack(spacing: 8) {
232 HStack(spacing: 8) {
@@ -255,23 +253,31 @@ struct MenuView: View {
255
253
256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
257 VStack(alignment: .leading, spacing: 12) {
255 VStack(alignment: .leading, spacing: 12) {
258− // The 5-hour window leads as the hero number, since it's the one
256+ // The 5-hour window leads as the headline number.
259− // most likely to bind and the one shown in the menu bar title.
260 if let fiveHour = usage.fiveHour {
257 if let fiveHour = usage.fiveHour {
261 let percent = Int(fiveHour.utilization.rounded())
258 let percent = Int(fiveHour.utilization.rounded())
259+ VStack(spacing: 8) {
262− HStack(alignment: .firstTextBaseline, spacing: 8) {
260+ HStack(alignment: .firstTextBaseline, spacing: 8) {
261+ headlineNumber(
263− Text("🕐 \(percent)%")
262+ text: "⁕ \\(percent)%",
264− .font(Self.subheroFont)
265− .foregroundColor(Self.usageColor(percent))
263+ color: Self.usageColor(percent),
266− .contentTransition(.opacity)
264+ trend: viewModel.claudeTrend,
265+ copiedConfirmation: viewModel.copiedClaudeConfirmation,
266+ captionText: "5-hour usage",
267+ copyAction: { viewModel.copyClaudePercentToClipboard() }
268+ )
267
269
268− Spacer(minLength: 0)
270+ Spacer(minLength: 0)
269
271
270− if let resets = fiveHour.resetsInFormatted {
272+ if let resets = fiveHour.resetsInFormatted {
271− Text("resets in \(resets)")
273+ Text("resets in \(resets)")
272− .font(Self.footnoteFont)
274+ .font(Self.footnoteFont)
273− .foregroundColor(.secondary)
275+ .foregroundColor(.secondary)
276+ }
274 }
277 }
278+
279+ // Bar under the 5-hour window too, same as 7-day windows below.
280+ UsageBar(percent: percent, color: Self.usageColor(percent))
275 }
281 }
276 }
282 }
277
283
@@ -285,6 +291,12 @@ struct MenuView: View {
285 window("7-day Opus", opus)
291 window("7-day Opus", opus)
286 }
292 }
287 }
293 }
294+
295+ // Sparkline for the Claude 5-hour % history.
296+ sparkline(
297+ values: viewModel.claudeSparklineValues,
298+ color: Self.usageColor(claudeFiveHourPercent ?? 0)
299+ )
288 }
300 }
289 }
301 }
290
302
@@ -310,6 +322,11 @@ struct MenuView: View {
310 }
322 }
311 }
323 }
312
324
325+ /// The current Claude 5-hour percentage, or 0 if absent. Used for sparkline color.
326+ private var claudeFiveHourPercent: Int? {
327+ viewModel.claudeFiveHourPercent
328+ }
329+
313 /// The balance colors read the other way round here, because for a limit more is
330 /// The balance colors read the other way round here, because for a limit more is
314 /// worse: green with room to spare, yellow closing in, red nearly spent.
331 /// worse: green with room to spare, yellow closing in, red nearly spent.
315 private static func usageColor(_ percent: Int) -> Color {
332 private static func usageColor(_ percent: Int) -> Color {
@@ -329,8 +346,8 @@ struct MenuView: View {
329 return false
346 return false
330 }
347 }
331
348
332− /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
349+ /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
333− /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
350+ /// a subhero headline number, bars under every window, a sparkline, and error row.
334 private var zai: some View {
351 private var zai: some View {
335 VStack(alignment: .leading, spacing: 12) {
352 VStack(alignment: .leading, spacing: 12) {
336 HStack(spacing: 8) {
353 HStack(spacing: 8) {
@@ -357,21 +374,30 @@ struct MenuView: View {
357
374
358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
359 VStack(alignment: .leading, spacing: 12) {
376 VStack(alignment: .leading, spacing: 12) {
360− // The 5-hour window leads as the hero number, same as Claude's.
377+ // The 5-hour window leads as the headline number.
361 if let fiveHour = usage.fiveHourPercent {
378 if let fiveHour = usage.fiveHourPercent {
379+ VStack(spacing: 8) {
362− HStack(alignment: .firstTextBaseline, spacing: 8) {
380+ HStack(alignment: .firstTextBaseline, spacing: 8) {
381+ headlineNumber(
363− Text("🤖 \(fiveHour)%")
382+ text: "Z \\(fiveHour)%",
364− .font(Self.subheroFont)
365− .foregroundColor(Self.usageColor(fiveHour))
383+ color: Self.usageColor(fiveHour),
366− .contentTransition(.opacity)
384+ trend: viewModel.zaiTrend,
385+ copiedConfirmation: viewModel.copiedZaiConfirmation,
386+ captionText: "5-hour usage",
387+ copyAction: { viewModel.copyZaiPercentToClipboard() }
388+ )
367
389
368− Spacer(minLength: 0)
390+ Spacer(minLength: 0)
369
391
370− if let resets = usage.fiveHourResetsAt {
392+ if let resets = usage.fiveHourResetsAt {
371− Text("resets in \(Self.zaiResetsIn(from: resets))")
393+ Text("resets in \(Self.zaiResetsIn(from: resets))")
372− .font(Self.footnoteFont)
394+ .font(Self.footnoteFont)
373− .foregroundColor(.secondary)
395+ .foregroundColor(.secondary)
396+ }
374 }
397 }
398+
399+ // Bar under the 5-hour window too.
400+ UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
375 }
401 }
376 }
402 }
377
403
@@ -380,9 +406,35 @@ struct MenuView: View {
380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
381 }
407 }
382 }
408 }
409+
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+ }
383 }
430 }
384 }
431 }
385
432
433+ /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
434+ private var zaiFiveHourPercent: Int? {
435+ viewModel.zaiFiveHourPercent
436+ }
437+
386 /// One window row for z.ai: label, percentage, and a hairline bar.
438 /// One window row for z.ai: label, percentage, and a hairline bar.
387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
439 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
388 VStack(alignment: .leading, spacing: 5) {
440 VStack(alignment: .leading, spacing: 5) {
@@ -402,6 +454,42 @@ struct MenuView: View {
402 }
454 }
403 }
455 }
404
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+
405 /// 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".
406 private static func zaiResetsIn(from date: Date) -> String {
494 private static func zaiResetsIn(from date: Date) -> String {
407 let interval = date.timeIntervalSince(Date())
495 let interval = date.timeIntervalSince(Date())
@@ -523,45 +611,10 @@ struct MenuView: View {
523 .keyboardShortcut("q")
611 .keyboardShortcut("q")
524 }
612 }
525 .controlSize(.small)
613 .controlSize(.small)
526−
527− Link("hyper.charm.land", destination: Self.websiteURL)
528− .font(Self.footnoteFont)
529− .foregroundColor(.secondary)
530− .opacity(0.7)
531− .frame(maxWidth: .infinity, alignment: .center)
532 }
614 }
533 }
615 }
534}
616}
535
617
536−// MARK: - Loading
537−
538−/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
539−/// the menu bar title rather than a generic spinner, so the two read as the same app.
540−private struct PulsingBolt: View {
541− @State private var isPulsing = false
542−
543− var body: some View {
544− VStack(spacing: 6) {
545− Text("⚡")
546− .font(.system(size: 34))
547− .opacity(isPulsing ? 0.35 : 1)
548− .scaleEffect(isPulsing ? 0.94 : 1)
549− .animation(
550− .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
551− value: isPulsing
552− )
553− // Roughly the height of the balance it stands in for, so the hero
554− // doesn't jump when the first fetch lands.
555− .frame(height: 56)
556−
557− Text("Loading…")
558− .font(.system(size: 11, weight: .medium, design: .rounded))
559− .foregroundColor(.secondary)
560− }
561− .onAppear { isPulsing = true }
562− }
563−}
564−
565// MARK: - Usage Bar
618// MARK: - Usage Bar
566
619
567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
620/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -592,8 +645,8 @@ private struct UsageBar: View {
592
645
593// MARK: - Sparkline
646// MARK: - Sparkline
594
647
595−/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
648+/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
596−/// takes the balance's color so the trend reads as part of the number above it, with a
649+/// takes the metric's color so the trend reads as part of the number above it, with a
597/// faint fade underneath for weight and a dot marking the latest point.
650/// faint fade underneath for weight and a dot marking the latest point.
598///
651///
599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
652/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -685,4 +738,4 @@ struct Sparkline: View {
685 path.closeSubpath()
738 path.closeSubpath()
686 }
739 }
687 }
740 }
688−}
741+}
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+223−42
@@ -7,11 +7,11 @@ 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 the balance has moved over the most recent history points.
13+/// The direction a metric has moved over the most recent history points.
14−enum BalanceTrend {
14+enum MetricTrend {
15 case increasing
15 case increasing
16 case decreasing
16 case decreasing
17 case stable
17 case stable
@@ -29,10 +29,11 @@ enum BalanceTrend {
29 }
29 }
30}
30}
31
31
32−/// A single balance observation.
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
33−struct BalancePoint: Equatable {
34+struct MetricPoint: Equatable {
34 let date: Date
35 let date: Date
35− let balance: Int
36+ let value: Int
36}
37}
37
38
38/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
39/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -111,8 +112,20 @@ final class ViewModel: ObservableObject {
111 /// Brief "✓ Copied" confirmation shown after copying the balance.
112 /// Brief "✓ Copied" confirmation shown after copying the balance.
112 @Published var copiedConfirmation: Bool = false
113 @Published var copiedConfirmation: Bool = false
113
114
115+ /// Brief "✓ Copied" confirmation shown after copying the Claude percentage.
116+ @Published var copiedClaudeConfirmation: Bool = false
117+
118+ /// Brief "✓ Copied" confirmation shown after copying the z.ai percentage.
119+ @Published var copiedZaiConfirmation: Bool = false
120+
114 /// Successful balance observations from the last 24 hours, oldest first.
121 /// Successful balance observations from the last 24 hours, oldest first.
115− @Published private(set) var history: [BalancePoint] = []
122+ @Published private(set) var history: [MetricPoint] = []
123+
124+ /// Successful Claude 5-hour % observations from the last 24 hours, oldest first.
125+ @Published private(set) var claudeHistory: [MetricPoint] = []
126+
127+ /// Successful z.ai 5-hour % observations from the last 24 hours, oldest first.
128+ @Published private(set) var zaiHistory: [MetricPoint] = []
116
129
117 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
118 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -163,6 +176,12 @@ final class ViewModel: ObservableObject {
163 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
164 private var savedResetTask: Task<Void, Never>?
177 private var savedResetTask: Task<Void, Never>?
165
178
179+ /// Resets `copiedClaudeConfirmation`; same rationale as `copiedResetTask`.
180+ private var copiedClaudeResetTask: Task<Void, Never>?
181+
182+ /// Resets `copiedZaiConfirmation`; same rationale as `copiedResetTask`.
183+ private var copiedZaiResetTask: Task<Void, Never>?
184+
166 // MARK: - History Configuration
185 // MARK: - History Configuration
167
186
168 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -170,6 +189,9 @@ final class ViewModel: ObservableObject {
170 private static let maxHistoryEntries = 300
189 private static let maxHistoryEntries = 300
171 private static let historyWindow: TimeInterval = 24 * 60 * 60
190 private static let historyWindow: TimeInterval = 24 * 60 * 60
172
191
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
173 init(
195 init(
174 checker: CreditsChecking = CreditsChecker(),
196 checker: CreditsChecking = CreditsChecker(),
175 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -215,11 +237,13 @@ final class ViewModel: ObservableObject {
215 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
216 }
238 }
217
239
218− /// 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
219− /// 5-hour), `📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and
241+ /// segment prefixed with a provider emoji; segments are separated by ` · `
220− /// `📆{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
221− /// absent when the service is not configured. An unconfigured service produces no
243+ /// separated by spaces. An unconfigured service produces no segment.
222− /// 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%🔶`
223 ///
247 ///
224 /// Takes its values as parameters rather than reading the properties: `@Published`
248 /// Takes its values as parameters rather than reading the properties: `@Published`
225 /// 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
@@ -231,30 +255,50 @@ final class ViewModel: ObservableObject {
231 claudeFiveHourPercent: Int?,
255 claudeFiveHourPercent: Int?,
232 claudeSevenDayPercent: Int?,
256 claudeSevenDayPercent: Int?,
233 zaiFiveHourPercent: Int?,
257 zaiFiveHourPercent: Int?,
234− zaiWeeklyPercent: Int?
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
235 ) -> String {
260 ) -> String {
236− var parts: [String] = []
261+ var segments: [String] = []
262+
263+ // Hyper
237 if hyperConfigured, let balance = balance {
264 if hyperConfigured, let balance = balance {
238− parts.append("⚡\(formatBalance(balance))")
265+ segments.append("⚡\(formatBalance(balance))")
239 }
266 }
267+
268+ // Claude — provider emoji + bare percentages
269+ var claudeWindows: [String] = []
240− if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
270+ if let fiveHour = claudeFiveHourPercent {
241− parts.append("🕐\(fiveHour)%")
271+ claudeWindows.append("\(fiveHour)%")
242 }
272 }
243− if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
273+ if let sevenDay = claudeSevenDayPercent {
244− parts.append("📅\(sevenDay)%")
274+ claudeWindows.append("\(sevenDay)%")
245 }
275 }
246− if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
276+ if !claudeWindows.isEmpty {
247− parts.append("🤖\(zaiFiveHour)%")
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
248 }
278 }
279+
280+ // z.ai — provider emoji + bare percentages
281+ var zaiWindows: [String] = []
282+ if let zaiFiveHour = zaiFiveHourPercent {
283+ zaiWindows.append("\(zaiFiveHour)%")
284+ }
249− if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
285+ if let zaiWeekly = zaiWeeklyPercent {
250− parts.append("📆\(zaiWeekly)%")
286+ zaiWindows.append("\(zaiWeekly)%")
251 }
287 }
288+ if !zaiWindows.isEmpty {
289+ var segment = "Z" + zaiWindows.joined(separator: " ")
290+ if zaiInPeakHours {
291+ segment += "🔶"
292+ }
293+ segments.append(segment)
294+ }
295+
252− if parts.isEmpty {
296+ if segments.isEmpty {
253 // No service is configured or has anything to show. While loading,
297 // No service is configured or has anything to show. While loading,
254 // show an ellipsis; otherwise the title is empty.
298 // show an ellipsis; otherwise the title is empty.
255 return isLoading ? "…" : ""
299 return isLoading ? "…" : ""
256 }
300 }
257− return parts.joined(separator: " · ")
301+ return segments.joined(separator: " · ")
258 }
302 }
259
303
260 /// 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.
@@ -266,10 +310,17 @@ final class ViewModel: ObservableObject {
266 claudeFiveHourPercent: claudeFiveHourPercent,
310 claudeFiveHourPercent: claudeFiveHourPercent,
267 claudeSevenDayPercent: claudeSevenDayPercent,
311 claudeSevenDayPercent: claudeSevenDayPercent,
268 zaiFiveHourPercent: zaiFiveHourPercent,
312 zaiFiveHourPercent: zaiFiveHourPercent,
269− zaiWeeklyPercent: zaiWeeklyPercent
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
270 )
315 )
271 }
316 }
272
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+
273 /// The formatted balance string for display in the popover.
324 /// The formatted balance string for display in the popover.
274 var formattedBalance: String {
325 var formattedBalance: String {
275 guard let balance = balance else { return "?" }
326 guard let balance = balance else { return "?" }
@@ -290,33 +341,69 @@ final class ViewModel: ObservableObject {
290 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
341 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
291 }
342 }
292
343
293− // MARK: - History
344+ // MARK: - History (Hyper)
294
345
295− /// The last 24 hours of successful fetches, oldest first.
346+ /// The last 24 hours of successful balance fetches, oldest first.
296 var balanceHistory: [(date: Date, balance: Int)] {
347 var balanceHistory: [(date: Date, balance: Int)] {
297− history.map { (date: $0.date, balance: $0.balance) }
348+ history.map { (date: $0.date, balance: $0.value) }
298 }
349 }
299
350
300 /// The direction of travel across the most recent handful of observations.
351 /// The direction of travel across the most recent handful of observations.
301 /// `.unknown` until there are at least two points to compare.
352 /// `.unknown` until there are at least two points to compare.
302− var balanceTrend: BalanceTrend {
353+ var balanceTrend: MetricTrend {
354+ metricTrend(history)
355+ }
356+
357+ /// The sparkline's data for the Hyper balance.
358+ var balanceSparklineValues: [Int] {
359+ history.map { $0.value }
360+ }
361+
362+ // MARK: - History (Claude)
363+
364+ /// The last 24 hours of successful Claude 5-hour % fetches, oldest first.
365+ var claudeSparklineValues: [Int] {
366+ claudeHistory.map { $0.value }
367+ }
368+
369+ /// The direction of travel for the Claude 5-hour %.
370+ var claudeTrend: MetricTrend {
371+ metricTrend(claudeHistory)
372+ }
373+
374+ // MARK: - History (z.ai)
375+
376+ /// The last 24 hours of successful z.ai 5-hour % fetches, oldest first.
377+ var zaiSparklineValues: [Int] {
378+ zaiHistory.map { $0.value }
379+ }
380+
381+ /// The direction of travel for the z.ai 5-hour %.
382+ var zaiTrend: MetricTrend {
383+ metricTrend(zaiHistory)
384+ }
385+
386+ // MARK: - History Helpers
387+
388+ /// Computes the trend from a generic history array.
389+ private func metricTrend(_ points: [MetricPoint]) -> MetricTrend {
303− guard history.count >= 2 else { return .unknown }
390+ guard points.count >= 2 else { return .unknown }
304− let recent = history.suffix(5)
391+ let recent = points.suffix(5)
305 guard let first = recent.first, let last = recent.last else { return .unknown }
392 guard let first = recent.first, let last = recent.last else { return .unknown }
306− if last.balance > first.balance { return .increasing }
393+ if last.value > first.value { return .increasing }
307− if last.balance < first.balance { return .decreasing }
394+ if last.value < first.value { return .decreasing }
308 return .stable
395 return .stable
309 }
396 }
310
397
311 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
398 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
312− private func recordHistory(balance: Int, at date: Date) {
399+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
313− history.append(BalancePoint(date: date, balance: balance))
400+ array.append(MetricPoint(date: date, value: value))
314
401
315 let cutoff = date.addingTimeInterval(-Self.historyWindow)
402 let cutoff = date.addingTimeInterval(-Self.historyWindow)
316− history.removeAll { $0.date < cutoff }
403+ array.removeAll { $0.date < cutoff }
317
404
318− if history.count > Self.maxHistoryEntries {
405+ if array.count > Self.maxHistoryEntries {
319− history.removeFirst(history.count - Self.maxHistoryEntries)
406+ array.removeFirst(array.count - Self.maxHistoryEntries)
320 }
407 }
321 }
408 }
322
409
@@ -391,7 +478,7 @@ final class ViewModel: ObservableObject {
391 errorMessage = nil
478 errorMessage = nil
392 let now = Date()
479 let now = Date()
393 lastUpdated = now
480 lastUpdated = now
394− recordHistory(balance: newBalance, at: now)
481+ recordMetric(newBalance, into: &history, at: now)
395
482
396 // Low-balance notification: on threshold crossing (≥10 → <10)
483 // Low-balance notification: on threshold crossing (≥10 → <10)
397 // OR on first successful fetch if already below threshold.
484 // OR on first successful fetch if already below threshold.
@@ -436,12 +523,30 @@ final class ViewModel: ObservableObject {
436 claudeUsage = nil
523 claudeUsage = nil
437 claudePlan = nil
524 claudePlan = nil
438 claudeError = nil
525 claudeError = nil
526+ claudeHistory.removeAll()
439
527
440 case .usage(let report):
528 case .usage(let report):
529+ // Capture the previous 5-hour % for threshold-crossing notification.
530+ let previousPercent = claudeFiveHourPercent
441 claudeUsage = report.usage
531 claudeUsage = report.usage
442 claudePlan = report.subscriptionType
532 claudePlan = report.subscriptionType
443 claudeError = nil
533 claudeError = nil
444
534
535+ // Record history and fire high-usage notification based on the 5-hour %.
536+ if let percent = claudeFiveHourPercent {
537+ let now = Date()
538+ recordMetric(percent, into: &claudeHistory, at: now)
539+
540+ // High-usage notification: on threshold crossing (<90 → ≥90)
541+ // OR on first successful fetch if already above threshold.
542+ if let previous = previousPercent, previous < Self.highUsageThreshold,
543+ percent >= Self.highUsageThreshold {
544+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
545+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
546+ sendHighUsageNotification(provider: "Claude Code", percent: percent)
547+ }
548+ }
549+
445 case .failure(let message):
550 case .failure(let message):
446 // As with the balance: keep the last good numbers on screen and say what
551 // As with the balance: keep the last good numbers on screen and say what
447 // went wrong, rather than blanking the section on a transient failure.
552 // went wrong, rather than blanking the section on a transient failure.
@@ -509,11 +614,29 @@ final class ViewModel: ObservableObject {
509 case .notConfigured:
614 case .notConfigured:
510 zaiUsage = nil
615 zaiUsage = nil
511 zaiError = nil
616 zaiError = nil
617+ zaiHistory.removeAll()
512
618
513 case .usage(let report):
619 case .usage(let report):
620+ // Capture the previous 5-hour % for threshold-crossing notification.
621+ let previousPercent = zaiFiveHourPercent
514 zaiUsage = report
622 zaiUsage = report
515 zaiError = nil
623 zaiError = nil
516
624
625+ // Record history and fire high-usage notification based on the 5-hour %.
626+ if let percent = zaiFiveHourPercent {
627+ let now = Date()
628+ recordMetric(percent, into: &zaiHistory, at: now)
629+
630+ // High-usage notification: on threshold crossing (<90 → ≥90)
631+ // OR on first successful fetch if already above threshold.
632+ if let previous = previousPercent, previous < Self.highUsageThreshold,
633+ percent >= Self.highUsageThreshold {
634+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
635+ } else if previousPercent == nil, percent >= Self.highUsageThreshold {
636+ sendHighUsageNotification(provider: "z.ai Coding", percent: percent)
637+ }
638+ }
639+
517 case .failure(let message):
640 case .failure(let message):
518 // As with the balance and Claude: keep the last good numbers on screen
641 // As with the balance and Claude: keep the last good numbers on screen
519 // and say what went wrong, rather than blanking the section on a
642 // and say what went wrong, rather than blanking the section on a
@@ -567,7 +690,41 @@ final class ViewModel: ObservableObject {
567 }
690 }
568 }
691 }
569
692
570− // MARK: - Low Balance Notification
693+ /// Copies the current Claude 5-hour percentage to the clipboard.
694+ func copyClaudePercentToClipboard() {
695+ guard let percent = claudeFiveHourPercent else { return }
696+
697+ let pasteboard = NSPasteboard.general
698+ pasteboard.clearContents()
699+ pasteboard.setString("\(percent)%", forType: .string)
700+
701+ copiedClaudeConfirmation = true
702+ copiedClaudeResetTask?.cancel()
703+ copiedClaudeResetTask = Task { @MainActor in
704+ try? await Task.sleep(nanoseconds: 1_500_000_000)
705+ guard !Task.isCancelled else { return }
706+ copiedClaudeConfirmation = false
707+ }
708+ }
709+
710+ /// Copies the current z.ai 5-hour percentage to the clipboard.
711+ func copyZaiPercentToClipboard() {
712+ guard let percent = zaiFiveHourPercent else { return }
713+
714+ let pasteboard = NSPasteboard.general
715+ pasteboard.clearContents()
716+ pasteboard.setString("\(percent)%", forType: .string)
717+
718+ copiedZaiConfirmation = true
719+ copiedZaiResetTask?.cancel()
720+ copiedZaiResetTask = Task { @MainActor in
721+ try? await Task.sleep(nanoseconds: 1_500_000_000)
722+ guard !Task.isCancelled else { return }
723+ copiedZaiConfirmation = false
724+ }
725+ }
726+
727+ // MARK: - Notifications
571
728
572 private func sendLowBalanceNotification(balance: Int) {
729 private func sendLowBalanceNotification(balance: Int) {
573 // Build the body here, on the main actor. `getNotificationSettings` calls back on
730 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -596,6 +753,29 @@ final class ViewModel: ObservableObject {
596 }
753 }
597 }
754 }
598
755
756+ /// Sends a notification when a usage percentage crosses the high-usage threshold.
757+ private func sendHighUsageNotification(provider: String, percent: Int) {
758+ let body = "\(provider) 5-hour usage at \(percent)% — approaching limit"
759+
760+ let center = UNUserNotificationCenter.current()
761+ center.getNotificationSettings { settings in
762+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
763+ return
764+ }
765+ let content = UNMutableNotificationContent()
766+ content.title = "High Usage Menubar"
767+ content.body = body
768+ content.sound = .default
769+
770+ let request = UNNotificationRequest(
771+ identifier: "high-usage-alert-\(provider)-\(UUID().uuidString)",
772+ content: content,
773+ trigger: nil
774+ )
775+ center.add(request)
776+ }
777+ }
778+
599 // MARK: - API Key
779 // MARK: - API Key
600
780
601 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
781 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -678,6 +858,7 @@ final class ViewModel: ObservableObject {
678 if key != activeZaiAPIKey {
858 if key != activeZaiAPIKey {
679 zaiUsage = nil
859 zaiUsage = nil
680 zaiError = nil
860 zaiError = nil
861+ zaiHistory.removeAll()
681 }
862 }
682 // Show the trimmed key that was actually stored.
863 // Show the trimmed key that was actually stored.
683 zaiAPIKeyInput = key
864 zaiAPIKeyInput = key
@@ -715,4 +896,4 @@ final class ViewModel: ObservableObject {
715 isInitializingLaunchAtLogin = false
896 isInitializingLaunchAtLogin = false
716 }
897 }
717 }
898 }
718−}
899+}
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"