on ee9edd8 help sign in
9 files changed+284−604
since splitsnapshots
unifiedsplit
CLAUDE.md+10−35 README.md+6−6 UsageMenubar/Info.plist+1−1 UsageMenubar/KeychainHelper.swift+2−2 UsageMenubar/MenuView.swift+172−225 UsageMenubar/UsageMenubarApp.swift+1−2 UsageMenubar/ViewModel.swift+42−223 UsageMenubarTests/StatusBarTextTests.swift+46−106 project.yml+4−4
CLAUDE.md+10−35
@@ -1,51 +1,26 @@
11# UsageMenubar
22
33## Architecture
4−macOS menu bar app (SwiftUI, macOS 14+) showing three LLM providers with equal
5−visual treatment:
6−- **Hyper** (Charm) — credit balance
7−- **Claude Code** — Pro/Max usage limits (5-hour, 7-day, 7-day Opus)
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−
4+macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5+Claude Code's Pro/Max usage limits when the CLI is signed in on the machine, plus
6+z.ai Coding Plan usage limits when an API key is entered in settings.
147- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
15−- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, per-provider history
16−- `MenuView.swift` — SwiftUI popover view (3 equal provider sections, settings)
8+- `ViewModel.swift` — Observable state: balance, Claude usage, z.ai usage, refresh, notifications, history
9+- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, z.ai limits, settings)
1710- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
1811- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
1912- `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`)
2013- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
2114- `VersionFormatter.swift` — Version string formatting
2215
23−The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24−`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
16+The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
17+`📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and `📆{percent}%`
2518(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
2619service is not configured (no API key or no credentials). An unconfigured service
2720produces no placeholder in the menu bar. The three fetches are independent —
2821`ViewModel.refresh()` runs them with `async let`, and any can fail or be
2922unconfigured without touching the others.
3023
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−
4924## Claude credentials (strictly read-only)
5025`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
5126Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
@@ -75,9 +50,9 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
7550- Swift 5.9, macOS 14.0 deployment target
7651
7752## Conventions
78−- Clean minimal UI — subhero numbers, generous spacing, restrained color, no chrome
53+- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
7954- No card-heavy/multi-material/gradient-rich designs
80−- Rounded font family at one scale (subhero/section/control/caption/footnote)
55+- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
8156- Monospaced digits for numbers that change
8257- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
8358 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
@@ -85,4 +60,4 @@ Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials
8560
8661## VCS
8762- jj colocated with git
88−- Push directly to master — no PRs for this repo
63+- 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
99## Features
1010
1111- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12−- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13−- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
12+- 🤖 **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13+- 🤖 **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
1414- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
1515- 🖱️ **Right-click menu** — quick access to Refresh and Quit
1616- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
@@ -24,10 +24,10 @@ Each service shows an emoji-prefixed segment when configured and above 0%:
2424| Service | Emoji | Example |
2525|---------|-------|---------|
2626| Hyper balance | ⚡ | `⚡42` |
27−| Claude 5-hour | ⁕ | `⁕62%` |
28−| Claude 7-day | ⁕ | `⁕8%` |
29−| z.ai 5-hour | Z | `Z12%` |
30−| z.ai weekly | Z | `Z3%` |
27+| Claude 5-hour | 🕐 | `🕐62%` |
28+| Claude 7-day | 📅 | `📅8%` |
29+| z.ai 5-hour | 🤖 | `🤖12%` |
30+| z.ai weekly | 📆 | `📆3%` |
3131
3232Unconfigured services don't appear at all — no placeholder, no icon.
3333
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
77 <key>CFBundleDisplayName</key>
88 <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>soy.iko.usage-menubar</string>
10+ <string>com.ilyakooo0.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
8282
8383/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
8484enum KeychainHelper {
85− private static let service = "soy.iko.usage-menubar"
85+ private static let service = "com.ilyakooo0.usage-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112112/// Uses the same keychain service as the Hyper key but a different account, so the
113113/// two keys coexist without clobbering each other.
114114enum ZaiKeychainHelper {
115− private static let service = "soy.iko.usage-menubar"
115+ private static let service = "com.ilyakooo0.usage-menubar"
116116 private static let account = "zai-api-key"
117117
118118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+172−225
@@ -4,15 +4,21 @@ import SwiftUI
44struct MenuView: View {
55 @ObservedObject var viewModel: ViewModel
66
7+ /// Dims the balance while the pointer is over it, hinting that it is clickable.
8+ @State private var isHoveringBalance = false
9+
710 private static let websiteURL = URL(string: "https://hyper.charm.land")!
811 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
912
1013 // MARK: - Type Scale
1114
12− /// One rounded family, sized so that every provider's headline number reads at the
13− /// same weight. With three providers in the popover, the 26pt subhero is the right
14− /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15− /// numbers from jittering as their digits change.
15+ /// One rounded family, sized so that the balance is the only large element and
16+ /// everything else steps down from it — nothing competes with it for attention.
17+ /// The Claude percentage is the one exception, and it is deliberately set well
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.
20+ private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
21+ .monospacedDigit()
1622 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
1723 .monospacedDigit()
1824 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -22,9 +28,7 @@ struct MenuView: View {
2228
2329 var body: some View {
2430 VStack(alignment: .leading, spacing: 18) {
25− if viewModel.hyperConfigured {
26− hyper
27− }
31+ hyper
2832 if showsClaude {
2933 serviceBreak
3034 claude
@@ -47,14 +51,10 @@ struct MenuView: View {
4751 .background(Color(nsColor: .windowBackgroundColor))
4852 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
4953 .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)
5254 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
5355 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
5456 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
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)
57+ .animation(.easeInOut(duration: 0.3), value: historyValues.count)
5858 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
5959 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
6060 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,14 +66,15 @@ struct MenuView: View {
6666 Divider().opacity(0.3)
6767 }
6868
69− /// The rule between services. Wider than the others on purpose: the providers are
70− /// unrelated accounts, and the gap should say so before the headers do.
69+ /// The rule between the two services. Wider than the others on purpose: Hyper and
70+ /// Claude are two unrelated accounts, and the gap should say so before the headers do.
7171 private var serviceBreak: some View {
7272 hairline.padding(.vertical, 5)
7373 }
7474
7575 /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three equal sections rather than a hero with appendages.
76+ /// so the popover reads as three sections rather than a hero with two appendices.
77+ /// The icon is what tells the two services apart at a glance.
7778 private func sectionHeader(_ title: String, systemImage: String) -> some View {
7879 HStack(spacing: 5) {
7980 Image(systemName: systemImage)
@@ -84,62 +85,113 @@ struct MenuView: View {
8485 }
8586 }
8687
87− /// A reusable headline number row: the provider's main metric at subhero scale,
88− /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
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
97− ) -> some View {
98− VStack(spacing: 6) {
99− Button(action: copyAction) {
100− HStack(alignment: .firstTextBaseline, spacing: 6) {
101− Text(text)
102− .font(Self.subheroFont)
103− .foregroundColor(color)
104− .contentTransition(.opacity)
88+ // MARK: - Hyper
10589
106− if let symbol = trend.symbolName {
107− Image(systemName: symbol)
108− .font(.system(size: 12, weight: .semibold))
109− .foregroundColor(.secondary)
110− .transition(.opacity)
111− }
90+ /// The Hyper section: its header, then the balance as the popover's hero.
91+ private var hyper: some View {
92+ VStack(alignment: .leading, spacing: 12) {
93+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
94+
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("⚡?")
107+ .font(Self.heroFont)
108+ .foregroundColor(.secondary)
109+
110+ Text("Enter your API key below")
111+ .font(Self.captionFont)
112+ .foregroundColor(.secondary)
113+ }
114+ .transition(.opacity)
115+ } else {
116+ VStack(spacing: 6) {
117+ balanceNumber
118+ caption
119+ sparkline
112120 }
113− .contentShape(Rectangle())
121+ .transition(.opacity)
114122 }
115− .buttonStyle(.plain)
116− .help("Click to copy")
117−
118− // Caption doubles as copy confirmation so the layout doesn't shift.
119− Group {
120− if copiedConfirmation {
121− Text("✓ Copied")
122− .foregroundColor(.green)
123− } else {
124− Text(captionText)
123+
124+ if let lastUpdatedText = viewModel.lastUpdatedText {
125+ Text(lastUpdatedText)
126+ .font(Self.footnoteFont)
127+ .foregroundColor(.secondary)
128+ .transition(.opacity)
129+ }
130+
131+ if let error = viewModel.errorMessage {
132+ errorRow(error)
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))
125151 .foregroundColor(.secondary)
152+ .transition(.opacity)
126153 }
127154 }
128− .font(Self.captionFont)
155+ // Without this the hover area is only the glyphs themselves.
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+ }
129176 }
177+ .font(Self.captionFont)
130178 }
131179
132− /// A sparkline shown when there are at least 2 history points.
133180 @ViewBuilder
134− private func sparkline(values: [Int], color: Color) -> some View {
135− if values.count >= 2 {
136− Sparkline(values: values, color: color)
181+ private var sparkline: some View {
182+ if historyValues.count >= 2 {
183+ Sparkline(values: historyValues, color: viewModel.balanceColor)
137184 .frame(height: 26)
138185 .padding(.top, 4)
139186 .transition(.opacity)
140187 }
141188 }
142189
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+
143195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144196 /// message, and a tinted rounded background with no border.
145197 private func errorRow(_ message: String) -> some View {
@@ -164,56 +216,6 @@ struct MenuView: View {
164216 .padding(.top, 2)
165217 }
166218
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−
217219 // MARK: - Claude
218220
219221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -225,8 +227,8 @@ struct MenuView: View {
225227 return false
226228 }
227229
228− /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229− /// headline number, bars under every window, a sparkline, and error row.
230+ /// Claude Code's plan limits. The percentage leads, as the balance does above it,
231+ /// with the individual windows underneath as quiet supporting detail.
230232 private var claude: some View {
231233 VStack(alignment: .leading, spacing: 12) {
232234 HStack(spacing: 8) {
@@ -253,31 +255,23 @@ struct MenuView: View {
253255
254256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
255257 VStack(alignment: .leading, spacing: 12) {
256− // The 5-hour window leads as the headline number.
258+ // The 5-hour window leads as the hero number, since it's the one
259+ // most likely to bind and the one shown in the menu bar title.
257260 if let fiveHour = usage.fiveHour {
258261 let percent = Int(fiveHour.utilization.rounded())
259− VStack(spacing: 8) {
260− HStack(alignment: .firstTextBaseline, spacing: 8) {
261− headlineNumber(
262− text: "⁕ \\(percent)%",
263− color: Self.usageColor(percent),
264− trend: viewModel.claudeTrend,
265− copiedConfirmation: viewModel.copiedClaudeConfirmation,
266− captionText: "5-hour usage",
267− copyAction: { viewModel.copyClaudePercentToClipboard() }
268− )
262+ HStack(alignment: .firstTextBaseline, spacing: 8) {
263+ Text("🕐 \(percent)%")
264+ .font(Self.subheroFont)
265+ .foregroundColor(Self.usageColor(percent))
266+ .contentTransition(.opacity)
269267
270− Spacer(minLength: 0)
268+ Spacer(minLength: 0)
271269
272− if let resets = fiveHour.resetsInFormatted {
273− Text("resets in \(resets)")
274− .font(Self.footnoteFont)
275− .foregroundColor(.secondary)
276− }
270+ if let resets = fiveHour.resetsInFormatted {
271+ Text("resets in \(resets)")
272+ .font(Self.footnoteFont)
273+ .foregroundColor(.secondary)
277274 }
278−
279− // Bar under the 5-hour window too, same as 7-day windows below.
280− UsageBar(percent: percent, color: Self.usageColor(percent))
281275 }
282276 }
283277
@@ -291,12 +285,6 @@ struct MenuView: View {
291285 window("7-day Opus", opus)
292286 }
293287 }
294−
295− // Sparkline for the Claude 5-hour % history.
296− sparkline(
297− values: viewModel.claudeSparklineValues,
298− color: Self.usageColor(claudeFiveHourPercent ?? 0)
299− )
300288 }
301289 }
302290
@@ -322,11 +310,6 @@ struct MenuView: View {
322310 }
323311 }
324312
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−
330313 /// The balance colors read the other way round here, because for a limit more is
331314 /// worse: green with room to spare, yellow closing in, red nearly spent.
332315 private static func usageColor(_ percent: Int) -> Color {
@@ -346,8 +329,8 @@ struct MenuView: View {
346329 return false
347330 }
348331
349− /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350− /// a subhero headline number, bars under every window, a sparkline, and error row.
332+ /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
333+ /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
351334 private var zai: some View {
352335 VStack(alignment: .leading, spacing: 12) {
353336 HStack(spacing: 8) {
@@ -374,30 +357,21 @@ struct MenuView: View {
374357
375358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376359 VStack(alignment: .leading, spacing: 12) {
377− // The 5-hour window leads as the headline number.
360+ // The 5-hour window leads as the hero number, same as Claude's.
378361 if let fiveHour = usage.fiveHourPercent {
379− VStack(spacing: 8) {
380− HStack(alignment: .firstTextBaseline, spacing: 8) {
381− headlineNumber(
382− text: "Z \\(fiveHour)%",
383− color: Self.usageColor(fiveHour),
384− trend: viewModel.zaiTrend,
385− copiedConfirmation: viewModel.copiedZaiConfirmation,
386− captionText: "5-hour usage",
387− copyAction: { viewModel.copyZaiPercentToClipboard() }
388− )
362+ HStack(alignment: .firstTextBaseline, spacing: 8) {
363+ Text("🤖 \(fiveHour)%")
364+ .font(Self.subheroFont)
365+ .foregroundColor(Self.usageColor(fiveHour))
366+ .contentTransition(.opacity)
389367
390− Spacer(minLength: 0)
368+ Spacer(minLength: 0)
391369
392− if let resets = usage.fiveHourResetsAt {
393− Text("resets in \(Self.zaiResetsIn(from: resets))")
394− .font(Self.footnoteFont)
395− .foregroundColor(.secondary)
396− }
370+ if let resets = usage.fiveHourResetsAt {
371+ Text("resets in \(Self.zaiResetsIn(from: resets))")
372+ .font(Self.footnoteFont)
373+ .foregroundColor(.secondary)
397374 }
398−
399− // Bar under the 5-hour window too.
400− UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
401375 }
402376 }
403377
@@ -406,35 +380,9 @@ struct MenuView: View {
406380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
407381 }
408382 }
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− }
430383 }
431384 }
432385
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−
438386 /// One window row for z.ai: label, percentage, and a hairline bar.
439387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
440388 VStack(alignment: .leading, spacing: 5) {
@@ -454,42 +402,6 @@ struct MenuView: View {
454402 }
455403 }
456404
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−
493405 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
494406 private static func zaiResetsIn(from date: Date) -> String {
495407 let interval = date.timeIntervalSince(Date())
@@ -611,10 +523,45 @@ struct MenuView: View {
611523 .keyboardShortcut("q")
612524 }
613525 .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)
614532 }
615533 }
616534}
617535
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+
618565// MARK: - Usage Bar
619566
620567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -645,8 +592,8 @@ private struct UsageBar: View {
645592
646593// MARK: - Sparkline
647594
648−/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
649−/// takes the metric's color so the trend reads as part of the number above it, with a
595+/// A minimal line chart of recent balances: 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
650597/// faint fade underneath for weight and a dot marking the latest point.
651598///
652599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -738,4 +685,4 @@ struct Sparkline: View {
738685 path.closeSubpath()
739686 }
740687 }
741−}
688+}
UsageMenubar/UsageMenubarApp.swift+1−2
@@ -89,8 +89,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
8989 claudeFiveHourPercent: fiveHour,
9090 claudeSevenDayPercent: sevenDay,
9191 zaiFiveHourPercent: zaiFiveHour,
92− zaiWeeklyPercent: zaiWeekly,
93− zaiInPeakHours: ViewModel.zaiInPeakHours
92+ zaiWeeklyPercent: zaiWeekly
9493 )
9594 }
9695 .removeDuplicates()
UsageMenubar/ViewModel.swift+42−223
@@ -7,11 +7,11 @@ import UserNotifications
77/// Posted when the user changes the refresh interval, so the app delegate can
88/// restart its timer with the new period.
99extension Notification.Name {
10− static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
1111}
1212
13−/// The direction a metric has moved over the most recent history points.
14−enum MetricTrend {
13+/// The direction the balance has moved over the most recent history points.
14+enum BalanceTrend {
1515 case increasing
1616 case decreasing
1717 case stable
@@ -29,11 +29,10 @@ enum MetricTrend {
2929 }
3030}
3131
32−/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33−/// and z.ai 5-hour % alike.
34−struct MetricPoint: Equatable {
32+/// A single balance observation.
33+struct BalancePoint: Equatable {
3534 let date: Date
36− let value: Int
35+ let balance: Int
3736}
3837
3938/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -112,20 +111,8 @@ final class ViewModel: ObservableObject {
112111 /// Brief "✓ Copied" confirmation shown after copying the balance.
113112 @Published var copiedConfirmation: Bool = false
114113
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−
121114 /// Successful balance observations from the last 24 hours, oldest first.
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] = []
115+ @Published private(set) var history: [BalancePoint] = []
129116
130117 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
131118 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -176,12 +163,6 @@ final class ViewModel: ObservableObject {
176163 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
177164 private var savedResetTask: Task<Void, Never>?
178165
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−
185166 // MARK: - History Configuration
186167
187168 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -189,9 +170,6 @@ final class ViewModel: ObservableObject {
189170 private static let maxHistoryEntries = 300
190171 private static let historyWindow: TimeInterval = 24 * 60 * 60
191172
192− /// The threshold above which a usage percentage triggers a notification.
193− private static let highUsageThreshold = 90
194−
195173 init(
196174 checker: CreditsChecking = CreditsChecker(),
197175 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -237,13 +215,11 @@ final class ViewModel: ObservableObject {
237215 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
238216 }
239217
240− /// The text to show in the menu bar. Each configured provider contributes a
241− /// segment prefixed with a provider emoji; segments are separated by ` · `
242− /// (middle dot). Within a segment, windows are listed as bare percentages
243− /// separated by spaces. An unconfigured service produces no segment.
244− /// When z.ai is in peak hours, a 🔶 marker is appended to the z.ai segment.
245− ///
246− /// Example: `⚡42 · ⁕62% 8% · Z12% 3%🔶`
218+ /// The text to show in the menu bar: `⚡{balance}` (Hyper), `🕐{percent}%` (Claude
219+ /// 5-hour), `📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and
220+ /// `📆{percent}%` (z.ai weekly) — each omitted when absent or at 0%, and entirely
221+ /// absent when the service is not configured. An unconfigured service produces no
222+ /// placeholder in the menu bar.
247223 ///
248224 /// Takes its values as parameters rather than reading the properties: `@Published`
249225 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -255,50 +231,30 @@ final class ViewModel: ObservableObject {
255231 claudeFiveHourPercent: Int?,
256232 claudeSevenDayPercent: Int?,
257233 zaiFiveHourPercent: Int?,
258− zaiWeeklyPercent: Int?,
259− zaiInPeakHours: Bool
234+ zaiWeeklyPercent: Int?
260235 ) -> String {
261− var segments: [String] = []
262−
263− // Hyper
236+ var parts: [String] = []
264237 if hyperConfigured, let balance = balance {
265− segments.append("⚡\(formatBalance(balance))")
238+ parts.append("⚡\(formatBalance(balance))")
266239 }
267−
268− // Claude — provider emoji + bare percentages
269− var claudeWindows: [String] = []
270− if let fiveHour = claudeFiveHourPercent {
271− claudeWindows.append("\(fiveHour)%")
240+ if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
241+ parts.append("🕐\(fiveHour)%")
272242 }
273− if let sevenDay = claudeSevenDayPercent {
274− claudeWindows.append("\(sevenDay)%")
243+ if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
244+ parts.append("📅\(sevenDay)%")
275245 }
276− if !claudeWindows.isEmpty {
277− segments.append("⁕" + claudeWindows.joined(separator: " "))
246+ if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
247+ parts.append("🤖\(zaiFiveHour)%")
278248 }
279−
280− // z.ai — provider emoji + bare percentages
281− var zaiWindows: [String] = []
282− if let zaiFiveHour = zaiFiveHourPercent {
283− zaiWindows.append("\(zaiFiveHour)%")
284− }
285− if let zaiWeekly = zaiWeeklyPercent {
286− zaiWindows.append("\(zaiWeekly)%")
249+ if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
250+ parts.append("📆\(zaiWeekly)%")
287251 }
288− if !zaiWindows.isEmpty {
289− var segment = "Z" + zaiWindows.joined(separator: " ")
290− if zaiInPeakHours {
291− segment += "🔶"
292− }
293− segments.append(segment)
294− }
295−
296− if segments.isEmpty {
252+ if parts.isEmpty {
297253 // No service is configured or has anything to show. While loading,
298254 // show an ellipsis; otherwise the title is empty.
299255 return isLoading ? "…" : ""
300256 }
301− return segments.joined(separator: " · ")
257+ return parts.joined(separator: " · ")
302258 }
303259
304260 /// The text to show in the menu bar for the current state.
@@ -310,17 +266,10 @@ final class ViewModel: ObservableObject {
310266 claudeFiveHourPercent: claudeFiveHourPercent,
311267 claudeSevenDayPercent: claudeSevenDayPercent,
312268 zaiFiveHourPercent: zaiFiveHourPercent,
313− zaiWeeklyPercent: zaiWeeklyPercent,
314− zaiInPeakHours: Self.zaiInPeakHours
269+ zaiWeeklyPercent: zaiWeeklyPercent
315270 )
316271 }
317272
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−
324273 /// The formatted balance string for display in the popover.
325274 var formattedBalance: String {
326275 guard let balance = balance else { return "?" }
@@ -341,69 +290,33 @@ final class ViewModel: ObservableObject {
341290 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
342291 }
343292
344− // MARK: - History (Hyper)
293+ // MARK: - History
345294
346− /// The last 24 hours of successful balance fetches, oldest first.
295+ /// The last 24 hours of successful fetches, oldest first.
347296 var balanceHistory: [(date: Date, balance: Int)] {
348− history.map { (date: $0.date, balance: $0.value) }
297+ history.map { (date: $0.date, balance: $0.balance) }
349298 }
350299
351300 /// The direction of travel across the most recent handful of observations.
352301 /// `.unknown` until there are at least two points to compare.
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 {
390− guard points.count >= 2 else { return .unknown }
391− let recent = points.suffix(5)
302+ var balanceTrend: BalanceTrend {
303+ guard history.count >= 2 else { return .unknown }
304+ let recent = history.suffix(5)
392305 guard let first = recent.first, let last = recent.last else { return .unknown }
393− if last.value > first.value { return .increasing }
394− if last.value < first.value { return .decreasing }
306+ if last.balance > first.balance { return .increasing }
307+ if last.balance < first.balance { return .decreasing }
395308 return .stable
396309 }
397310
398311 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
399− private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400− array.append(MetricPoint(date: date, value: value))
312+ private func recordHistory(balance: Int, at date: Date) {
313+ history.append(BalancePoint(date: date, balance: balance))
401314
402315 let cutoff = date.addingTimeInterval(-Self.historyWindow)
403− array.removeAll { $0.date < cutoff }
316+ history.removeAll { $0.date < cutoff }
404317
405− if array.count > Self.maxHistoryEntries {
406− array.removeFirst(array.count - Self.maxHistoryEntries)
318+ if history.count > Self.maxHistoryEntries {
319+ history.removeFirst(history.count - Self.maxHistoryEntries)
407320 }
408321 }
409322
@@ -478,7 +391,7 @@ final class ViewModel: ObservableObject {
478391 errorMessage = nil
479392 let now = Date()
480393 lastUpdated = now
481− recordMetric(newBalance, into: &history, at: now)
394+ recordHistory(balance: newBalance, at: now)
482395
483396 // Low-balance notification: on threshold crossing (≥10 → <10)
484397 // OR on first successful fetch if already below threshold.
@@ -523,30 +436,12 @@ final class ViewModel: ObservableObject {
523436 claudeUsage = nil
524437 claudePlan = nil
525438 claudeError = nil
526− claudeHistory.removeAll()
527439
528440 case .usage(let report):
529− // Capture the previous 5-hour % for threshold-crossing notification.
530− let previousPercent = claudeFiveHourPercent
531441 claudeUsage = report.usage
532442 claudePlan = report.subscriptionType
533443 claudeError = nil
534444
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−
550445 case .failure(let message):
551446 // As with the balance: keep the last good numbers on screen and say what
552447 // went wrong, rather than blanking the section on a transient failure.
@@ -614,29 +509,11 @@ final class ViewModel: ObservableObject {
614509 case .notConfigured:
615510 zaiUsage = nil
616511 zaiError = nil
617− zaiHistory.removeAll()
618512
619513 case .usage(let report):
620− // Capture the previous 5-hour % for threshold-crossing notification.
621− let previousPercent = zaiFiveHourPercent
622514 zaiUsage = report
623515 zaiError = nil
624516
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−
640517 case .failure(let message):
641518 // As with the balance and Claude: keep the last good numbers on screen
642519 // and say what went wrong, rather than blanking the section on a
@@ -690,41 +567,7 @@ final class ViewModel: ObservableObject {
690567 }
691568 }
692569
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
570+ // MARK: - Low Balance Notification
728571
729572 private func sendLowBalanceNotification(balance: Int) {
730573 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -753,29 +596,6 @@ final class ViewModel: ObservableObject {
753596 }
754597 }
755598
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−
779599 // MARK: - API Key
780600
781601 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -858,7 +678,6 @@ final class ViewModel: ObservableObject {
858678 if key != activeZaiAPIKey {
859679 zaiUsage = nil
860680 zaiError = nil
861− zaiHistory.removeAll()
862681 }
863682 // Show the trimmed key that was actually stored.
864683 zaiAPIKeyInput = key
@@ -896,4 +715,4 @@ final class ViewModel: ObservableObject {
896715 isInitializingLaunchAtLogin = false
897716 }
898717 }
899−}
718+}
UsageMenubarTests/StatusBarTextTests.swift+46−106
@@ -15,10 +15,9 @@ final class StatusBarTextTests: XCTestCase {
1515 balance: 42, isLoading: false,
1616 hyperConfigured: true,
1717 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19− zaiInPeakHours: false
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
2019 )
21− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
20+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
2221 }
2322
2423 func testHyperOnly() {
@@ -26,8 +25,7 @@ final class StatusBarTextTests: XCTestCase {
2625 balance: 42, isLoading: false,
2726 hyperConfigured: true,
2827 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
29− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30− zaiInPeakHours: false
28+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
3129 )
3230 XCTAssertEqual(title, "⚡42")
3331 }
@@ -37,10 +35,9 @@ final class StatusBarTextTests: XCTestCase {
3735 balance: nil, isLoading: false,
3836 hyperConfigured: false,
3937 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
40− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41− zaiInPeakHours: false
38+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
4239 )
43− XCTAssertEqual(title, "\u{2055}62% 8%")
40+ XCTAssertEqual(title, "🕐62% · 📅8%")
4441 }
4542
4643 func testLoadingWithNothingToShow() {
@@ -48,8 +45,7 @@ final class StatusBarTextTests: XCTestCase {
4845 balance: nil, isLoading: true,
4946 hyperConfigured: false,
5047 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
51− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52− zaiInPeakHours: false
48+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
5349 )
5450 XCTAssertEqual(title, "…")
5551 }
@@ -59,8 +55,7 @@ final class StatusBarTextTests: XCTestCase {
5955 balance: nil, isLoading: false,
6056 hyperConfigured: false,
6157 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
62− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63− zaiInPeakHours: false
58+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
6459 )
6560 XCTAssertEqual(title, "")
6661 }
@@ -73,10 +68,9 @@ final class StatusBarTextTests: XCTestCase {
7368 balance: nil, isLoading: true,
7469 hyperConfigured: false,
7570 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
76− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77− zaiInPeakHours: false
71+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
7872 )
79− XCTAssertEqual(title, "\u{2055}62%")
73+ XCTAssertEqual(title, "🕐62%")
8074 }
8175
8276 func testLoadingKeepsAStaleBalanceOnScreen() {
@@ -84,21 +78,19 @@ final class StatusBarTextTests: XCTestCase {
8478 balance: 42, isLoading: true,
8579 hyperConfigured: true,
8680 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
87− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88− zaiInPeakHours: false
81+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
8982 )
90− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
83+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
9184 }
9285
93− func testZeroPercentIsShown() {
86+ func testZeroPercentIsShownRatherThanOmitted() {
9487 let title = ViewModel.statusBarText(
9588 balance: 42, isLoading: false,
9689 hyperConfigured: true,
9790 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
98− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99− zaiInPeakHours: false
91+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
10092 )
101− XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
93+ XCTAssertEqual(title, "⚡42 · 📅8%")
10294 }
10395
10496 func testBothClaudeWindowsZero() {
@@ -106,10 +98,9 @@ final class StatusBarTextTests: XCTestCase {
10698 balance: 42, isLoading: false,
10799 hyperConfigured: true,
108100 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
109− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110− zaiInPeakHours: false
101+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
111102 )
112− XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
103+ XCTAssertEqual(title, "⚡42")
113104 }
114105
115106 func testFiveHourOnly() {
@@ -117,10 +108,9 @@ final class StatusBarTextTests: XCTestCase {
117108 balance: nil, isLoading: false,
118109 hyperConfigured: false,
119110 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
120− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121− zaiInPeakHours: false
111+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
122112 )
123− XCTAssertEqual(title, "\u{2055}62%")
113+ XCTAssertEqual(title, "🕐62%")
124114 }
125115
126116 func testSevenDayOnly() {
@@ -128,10 +118,9 @@ final class StatusBarTextTests: XCTestCase {
128118 balance: nil, isLoading: false,
129119 hyperConfigured: false,
130120 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
131− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132− zaiInPeakHours: false
121+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
133122 )
134− XCTAssertEqual(title, "\u{2055}8%")
123+ XCTAssertEqual(title, "📅8%")
135124 }
136125
137126 // MARK: - Hyper not configured
@@ -143,8 +132,7 @@ final class StatusBarTextTests: XCTestCase {
143132 balance: 42, isLoading: false,
144133 hyperConfigured: false,
145134 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
146− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147− zaiInPeakHours: false
135+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
148136 )
149137 XCTAssertEqual(title, "")
150138 }
@@ -154,8 +142,7 @@ final class StatusBarTextTests: XCTestCase {
154142 balance: nil, isLoading: true,
155143 hyperConfigured: false,
156144 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158− zaiInPeakHours: false
145+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
159146 )
160147 XCTAssertEqual(title, "…")
161148 }
@@ -165,10 +152,9 @@ final class StatusBarTextTests: XCTestCase {
165152 balance: nil, isLoading: false,
166153 hyperConfigured: false,
167154 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
168− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169− zaiInPeakHours: false
155+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
170156 )
171− XCTAssertEqual(title, "\u{2055}62% 8%")
157+ XCTAssertEqual(title, "🕐62% · 📅8%")
172158 }
173159
174160 // MARK: - z.ai in Title
@@ -178,10 +164,9 @@ final class StatusBarTextTests: XCTestCase {
178164 balance: nil, isLoading: false,
179165 hyperConfigured: false,
180166 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
181− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182− zaiInPeakHours: false
167+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
183168 )
184− XCTAssertEqual(title, "\u{005A}12%")
169+ XCTAssertEqual(title, "🤖12%")
185170 }
186171
187172 func testZaiWithHyper() {
@@ -189,10 +174,9 @@ final class StatusBarTextTests: XCTestCase {
189174 balance: 42, isLoading: false,
190175 hyperConfigured: true,
191176 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
192− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193− zaiInPeakHours: false
177+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
194178 )
195− XCTAssertEqual(title, "⚡42 · \u{005A}12%")
179+ XCTAssertEqual(title, "⚡42 · 🤖12%")
196180 }
197181
198182 func testZaiWithClaude() {
@@ -200,10 +184,9 @@ final class StatusBarTextTests: XCTestCase {
200184 balance: nil, isLoading: false,
201185 hyperConfigured: false,
202186 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
203− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204− zaiInPeakHours: false
187+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
205188 )
206− XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
189+ XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
207190 }
208191
209192 func testAllThreeServices() {
@@ -211,21 +194,19 @@ final class StatusBarTextTests: XCTestCase {
211194 balance: 42, isLoading: false,
212195 hyperConfigured: true,
213196 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
214− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215− zaiInPeakHours: false
197+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
216198 )
217− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
199+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12% · 📆3%")
218200 }
219201
220− func testZaiZeroIsShown() {
202+ func testZaiZeroIsOmitted() {
221203 let title = ViewModel.statusBarText(
222204 balance: 42, isLoading: false,
223205 hyperConfigured: true,
224206 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
225− zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226− zaiInPeakHours: false
207+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0
227208 )
228− XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
209+ XCTAssertEqual(title, "⚡42")
229210 }
230211
231212 func testLoadingKeepsZaiPercentOnScreen() {
@@ -233,10 +214,9 @@ final class StatusBarTextTests: XCTestCase {
233214 balance: nil, isLoading: true,
234215 hyperConfigured: false,
235216 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
236− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237− zaiInPeakHours: false
217+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
238218 )
239− XCTAssertEqual(title, "\u{005A}12%")
219+ XCTAssertEqual(title, "🤖12%")
240220 }
241221
242222 // MARK: - z.ai Weekly
@@ -246,10 +226,9 @@ final class StatusBarTextTests: XCTestCase {
246226 balance: nil, isLoading: false,
247227 hyperConfigured: false,
248228 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250− zaiInPeakHours: false
229+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
251230 )
252− XCTAssertEqual(title, "\u{005A}3%")
231+ XCTAssertEqual(title, "📆3%")
253232 }
254233
255234 func testZaiWeeklyWithFiveHour() {
@@ -257,21 +236,19 @@ final class StatusBarTextTests: XCTestCase {
257236 balance: nil, isLoading: false,
258237 hyperConfigured: false,
259238 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
260− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261− zaiInPeakHours: false
239+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
262240 )
263− XCTAssertEqual(title, "\u{005A}12% 3%")
241+ XCTAssertEqual(title, "🤖12% · 📆3%")
264242 }
265243
266− func testZaiWeeklyZeroIsShown() {
244+ func testZaiWeeklyZeroIsOmitted() {
267245 let title = ViewModel.statusBarText(
268246 balance: nil, isLoading: false,
269247 hyperConfigured: false,
270248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
271− zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272− zaiInPeakHours: false
249+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0
273250 )
274− XCTAssertEqual(title, "\u{005A}12% 0%")
251+ XCTAssertEqual(title, "🤖12%")
275252 }
276253
277254 func testLoadingKeepsZaiWeeklyOnScreen() {
@@ -279,46 +256,9 @@ final class StatusBarTextTests: XCTestCase {
279256 balance: nil, isLoading: true,
280257 hyperConfigured: false,
281258 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
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
259+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
319260 )
320− // No z.ai segment, so peak marker has nothing to attach to.
321− XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
261+ XCTAssertEqual(title, "📆3%")
322262 }
323263
324264 // MARK: - Claude Window Percentages
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
66options:
77 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
88 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: soy.iko
9+ bundleIdPrefix: com.ilyakooo0
1010 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
1111 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
1212 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
4747 properties:
4848 CFBundleName: UsageMenubar
4949 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: soy.iko.usage-menubar
50+ CFBundleIdentifier: com.ilyakooo0.usage-menubar
5151 # Overwritten by CI — see the note on MARKETING_VERSION above.
5252 CFBundleVersion: "1"
5353 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
5757 NSHighResolutionCapable: true
5858 settings:
5959 base:
60− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
6161 PRODUCT_NAME: UsageMenubar
6262 INFOPLIST_FILE: UsageMenubar/Info.plist
6363 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
8484 - target: UsageMenubar
8585 settings:
8686 base:
87− PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
8888 PRODUCT_NAME: UsageMenubarTests
8989 BUNDLE_LOADER: "$(TEST_HOST)"
9090 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"