on master help sign in
9 files changed+604−284
since splitsnapshots
unifiedsplit
CLAUDE.md+35−10 README.md+6−6 UsageMenubar/Info.plist+1−1 UsageMenubar/KeychainHelper.swift+2−2 UsageMenubar/MenuView.swift+225−172 UsageMenubar/UsageMenubarApp.swift+2−1 UsageMenubar/ViewModel.swift+223−42 UsageMenubarTests/StatusBarTextTests.swift+106−46 project.yml+4−4
CLAUDE.md+35−10
@@ -1,26 +1,51 @@
11# UsageMenubar
22
33## Architecture
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.
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+
714- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
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)
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)
1017- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
1118- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
1219- `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`)
1320- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
1421- `VersionFormatter.swift` — Version string formatting
1522
16−The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
17−`📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and `📆{percent}%`
23+The status bar title shows `⚡{balance}` (Hyper), `⁕{percent}%` (Claude 5-hour),
24+`⁕{percent}%` (Claude 7-day), `Z{percent}%` (z.ai 5-hour), and `Z{percent}%`
1825(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
1926service is not configured (no API key or no credentials). An unconfigured service
2027produces no placeholder in the menu bar. The three fetches are independent —
2128`ViewModel.refresh()` runs them with `async let`, and any can fail or be
2229unconfigured without touching the others.
2330
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+
2449## Claude credentials (strictly read-only)
2550`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
2651Keychain 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
5075- Swift 5.9, macOS 14.0 deployment target
5176
5277## 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
5479- 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)
5681- Monospaced digits for numbers that change
5782- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
5883 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
6085
6186## VCS
6287- 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
99## Features
1010
1111- ⚡ **Hyper credits** — balance from `hyper.charm.land`, with sparkline trend, click-to-copy, and low-balance notifications
12−- 🤖 **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13−- 🤖 **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
12+- ⁕ **Claude Code limits** — Pro/Max 5-hour and 7-day usage windows, with countdown to next reset. No setup: if you've signed in with the Claude Code CLI, it just appears
13+- Z **z.ai Coding Plan** — 5-hour and weekly token quota percentages, with reset countdowns. Enter your API key in settings
1414- 🔄 **Auto-refresh** — configurable interval (1m / 5m / 15m / 30m), plus refresh on wake from sleep
1515- 🖱️ **Right-click menu** — quick access to Refresh and Quit
1616- 🔒 **Keychain storage** — API keys stored securely in macOS Keychain
@@ -24,10 +24,10 @@ Each service shows an emoji-prefixed segment when configured and above 0%:
2424| Service | Emoji | Example |
2525|---------|-------|---------|
2626| Hyper balance | ⚡ | `⚡42` |
27−| Claude 5-hour | 🕐 | `🕐62%` |
28−| Claude 7-day | 📅 | `📅8%` |
29−| z.ai 5-hour | 🤖 | `🤖12%` |
30−| z.ai weekly | 📆 | `📆3%` |
27+| Claude 5-hour | ⁕ | `⁕62%` |
28+| Claude 7-day | ⁕ | `⁕8%` |
29+| z.ai 5-hour | Z | `Z12%` |
30+| z.ai weekly | Z | `Z3%` |
3131
3232Unconfigured services don't appear at all — no placeholder, no icon.
3333
UsageMenubar/Info.plist+1−1
@@ -7,7 +7,7 @@
77 <key>CFBundleDisplayName</key>
88 <string>Usage Menubar</string>
99 <key>CFBundleIdentifier</key>
10− <string>com.ilyakooo0.usage-menubar</string>
10+ <string>soy.iko.usage-menubar</string>
1111 <key>CFBundleVersion</key>
1212 <string>1</string>
1313 <key>CFBundleShortVersionString</key>
UsageMenubar/KeychainHelper.swift+2−2
@@ -82,7 +82,7 @@ struct KeychainStore {
8282
8383/// Simple wrapper around the macOS Keychain for storing the Hyper API key.
8484enum KeychainHelper {
85− private static let service = "com.ilyakooo0.usage-menubar"
85+ private static let service = "soy.iko.usage-menubar"
8686 private static let account = "hyper-api-key"
8787
8888 private static let store = KeychainStore(service: service, account: account)
@@ -112,7 +112,7 @@ enum KeychainHelper {
112112/// Uses the same keychain service as the Hyper key but a different account, so the
113113/// two keys coexist without clobbering each other.
114114enum ZaiKeychainHelper {
115− private static let service = "com.ilyakooo0.usage-menubar"
115+ private static let service = "soy.iko.usage-menubar"
116116 private static let account = "zai-api-key"
117117
118118 private static let store = KeychainStore(service: service, account: account)
UsageMenubar/MenuView.swift+225−172
@@ -4,21 +4,15 @@ 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−
107 private static let websiteURL = URL(string: "https://hyper.charm.land")!
118 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
129
1310 // MARK: - Type Scale
1411
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()
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.
2216 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
2317 .monospacedDigit()
2418 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -28,7 +22,9 @@ struct MenuView: View {
2822
2923 var body: some View {
3024 VStack(alignment: .leading, spacing: 18) {
31− hyper
25+ if viewModel.hyperConfigured {
26+ hyper
27+ }
3228 if showsClaude {
3329 serviceBreak
3430 claude
@@ -51,10 +47,14 @@ struct MenuView: View {
5147 .background(Color(nsColor: .windowBackgroundColor))
5248 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
5349 .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)
5452 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
5553 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
5654 .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)
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,15 +66,14 @@ struct MenuView: View {
6666 Divider().opacity(0.3)
6767 }
6868
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.
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.
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 sections rather than a hero with two appendices.
77− /// The icon is what tells the two services apart at a glance.
76+ /// so the popover reads as three equal sections rather than a hero with appendages.
7877 private func sectionHeader(_ title: String, systemImage: String) -> some View {
7978 HStack(spacing: 5) {
8079 Image(systemName: systemImage)
@@ -85,113 +84,62 @@ struct MenuView: View {
8584 }
8685 }
8786
88− // MARK: - Hyper
89−
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)
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)
109105
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
106+ if let symbol = trend.symbolName {
107+ Image(systemName: symbol)
108+ .font(.system(size: 12, weight: .semibold))
109+ .foregroundColor(.secondary)
110+ .transition(.opacity)
111+ }
120112 }
121− .transition(.opacity)
113+ .contentShape(Rectangle())
122114 }
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))
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)
151125 .foregroundColor(.secondary)
152− .transition(.opacity)
153126 }
154127 }
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− }
128+ .font(Self.captionFont)
176129 }
177− .font(Self.captionFont)
178130 }
179131
132+ /// A sparkline shown when there are at least 2 history points.
180133 @ViewBuilder
181− private var sparkline: some View {
182− if historyValues.count >= 2 {
183− Sparkline(values: historyValues, color: viewModel.balanceColor)
134+ private func sparkline(values: [Int], color: Color) -> some View {
135+ if values.count >= 2 {
136+ Sparkline(values: values, color: color)
184137 .frame(height: 26)
185138 .padding(.top, 4)
186139 .transition(.opacity)
187140 }
188141 }
189142
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−
195143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
196144 /// message, and a tinted rounded background with no border.
197145 private func errorRow(_ message: String) -> some View {
@@ -216,6 +164,56 @@ struct MenuView: View {
216164 .padding(.top, 2)
217165 }
218166
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+
219217 // MARK: - Claude
220218
221219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -227,8 +225,8 @@ struct MenuView: View {
227225 return false
228226 }
229227
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.
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.
232230 private var claude: some View {
233231 VStack(alignment: .leading, spacing: 12) {
234232 HStack(spacing: 8) {
@@ -255,23 +253,31 @@ struct MenuView: View {
255253
256254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
257255 VStack(alignment: .leading, spacing: 12) {
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.
256+ // The 5-hour window leads as the headline number.
260257 if let fiveHour = usage.fiveHour {
261258 let percent = Int(fiveHour.utilization.rounded())
262− HStack(alignment: .firstTextBaseline, spacing: 8) {
263− Text("🕐 \(percent)%")
264− .font(Self.subheroFont)
265− .foregroundColor(Self.usageColor(percent))
266− .contentTransition(.opacity)
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+ )
267269
268− Spacer(minLength: 0)
270+ Spacer(minLength: 0)
269271
270− if let resets = fiveHour.resetsInFormatted {
271− Text("resets in \(resets)")
272− .font(Self.footnoteFont)
273− .foregroundColor(.secondary)
272+ if let resets = fiveHour.resetsInFormatted {
273+ Text("resets in \(resets)")
274+ .font(Self.footnoteFont)
275+ .foregroundColor(.secondary)
276+ }
274277 }
278+
279+ // Bar under the 5-hour window too, same as 7-day windows below.
280+ UsageBar(percent: percent, color: Self.usageColor(percent))
275281 }
276282 }
277283
@@ -285,6 +291,12 @@ struct MenuView: View {
285291 window("7-day Opus", opus)
286292 }
287293 }
294+
295+ // Sparkline for the Claude 5-hour % history.
296+ sparkline(
297+ values: viewModel.claudeSparklineValues,
298+ color: Self.usageColor(claudeFiveHourPercent ?? 0)
299+ )
288300 }
289301 }
290302
@@ -310,6 +322,11 @@ struct MenuView: View {
310322 }
311323 }
312324
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+
313330 /// The balance colors read the other way round here, because for a limit more is
314331 /// worse: green with room to spare, yellow closing in, red nearly spent.
315332 private static func usageColor(_ percent: Int) -> Color {
@@ -329,8 +346,8 @@ struct MenuView: View {
329346 return false
330347 }
331348
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.
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.
334351 private var zai: some View {
335352 VStack(alignment: .leading, spacing: 12) {
336353 HStack(spacing: 8) {
@@ -357,21 +374,30 @@ struct MenuView: View {
357374
358375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
359376 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.
361378 if let fiveHour = usage.fiveHourPercent {
362− HStack(alignment: .firstTextBaseline, spacing: 8) {
363− Text("🤖 \(fiveHour)%")
364− .font(Self.subheroFont)
365− .foregroundColor(Self.usageColor(fiveHour))
366− .contentTransition(.opacity)
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+ )
367389
368− Spacer(minLength: 0)
390+ Spacer(minLength: 0)
369391
370− if let resets = usage.fiveHourResetsAt {
371− Text("resets in \(Self.zaiResetsIn(from: resets))")
372− .font(Self.footnoteFont)
373− .foregroundColor(.secondary)
392+ if let resets = usage.fiveHourResetsAt {
393+ Text("resets in \(Self.zaiResetsIn(from: resets))")
394+ .font(Self.footnoteFont)
395+ .foregroundColor(.secondary)
396+ }
374397 }
398+
399+ // Bar under the 5-hour window too.
400+ UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
375401 }
376402 }
377403
@@ -380,9 +406,35 @@ struct MenuView: View {
380406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
381407 }
382408 }
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+ }
383430 }
384431 }
385432
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+
386438 /// One window row for z.ai: label, percentage, and a hairline bar.
387439 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
388440 VStack(alignment: .leading, spacing: 5) {
@@ -402,6 +454,42 @@ struct MenuView: View {
402454 }
403455 }
404456
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+
405493 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
406494 private static func zaiResetsIn(from date: Date) -> String {
407495 let interval = date.timeIntervalSince(Date())
@@ -523,45 +611,10 @@ struct MenuView: View {
523611 .keyboardShortcut("q")
524612 }
525613 .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)
532614 }
533615 }
534616}
535617
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−
565618// MARK: - Usage Bar
566619
567620/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -592,8 +645,8 @@ private struct UsageBar: View {
592645
593646// MARK: - Sparkline
594647
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
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
597650/// faint fade underneath for weight and a dot marking the latest point.
598651///
599652/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -685,4 +738,4 @@ struct Sparkline: View {
685738 path.closeSubpath()
686739 }
687740 }
688−}
741+}
UsageMenubar/UsageMenubarApp.swift+2−1
@@ -89,7 +89,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
8989 claudeFiveHourPercent: fiveHour,
9090 claudeSevenDayPercent: sevenDay,
9191 zaiFiveHourPercent: zaiFiveHour,
92− zaiWeeklyPercent: zaiWeekly
92+ zaiWeeklyPercent: zaiWeekly,
93+ zaiInPeakHours: ViewModel.zaiInPeakHours
9394 )
9495 }
9596 .removeDuplicates()
UsageMenubar/ViewModel.swift+223−42
@@ -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("com.ilyakooo0.usage-menubar.refreshIntervalDidChange")
10+ static let refreshIntervalDidChange = Notification.Name("soy.iko.usage-menubar.refreshIntervalDidChange")
1111}
1212
13−/// The direction the balance has moved over the most recent history points.
14−enum BalanceTrend {
13+/// The direction a metric has moved over the most recent history points.
14+enum MetricTrend {
1515 case increasing
1616 case decreasing
1717 case stable
@@ -29,10 +29,11 @@ enum BalanceTrend {
2929 }
3030}
3131
32−/// A single balance observation.
33−struct BalancePoint: Equatable {
32+/// A single metric observation — used for Hyper balance, Claude 5-hour %,
33+/// and z.ai 5-hour % alike.
34+struct MetricPoint: Equatable {
3435 let date: Date
35− let balance: Int
36+ let value: Int
3637}
3738
3839/// How often the balance is refreshed. Lives outside `ViewModel` so the view and
@@ -111,8 +112,20 @@ final class ViewModel: ObservableObject {
111112 /// Brief "✓ Copied" confirmation shown after copying the balance.
112113 @Published var copiedConfirmation: Bool = false
113114
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+
114121 /// 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] = []
116129
117130 /// How often the balance is refreshed, in minutes. Persisted to `UserDefaults`;
118131 /// changing it posts `.refreshIntervalDidChange` so the app delegate can
@@ -163,6 +176,12 @@ final class ViewModel: ObservableObject {
163176 /// Resets `savedConfirmation`; same rationale as `copiedResetTask`.
164177 private var savedResetTask: Task<Void, Never>?
165178
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+
166185 // MARK: - History Configuration
167186
168187 /// 24 hours at the shortest (1 minute) interval would overflow this, so the
@@ -170,6 +189,9 @@ final class ViewModel: ObservableObject {
170189 private static let maxHistoryEntries = 300
171190 private static let historyWindow: TimeInterval = 24 * 60 * 60
172191
192+ /// The threshold above which a usage percentage triggers a notification.
193+ private static let highUsageThreshold = 90
194+
173195 init(
174196 checker: CreditsChecking = CreditsChecker(),
175197 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
@@ -215,11 +237,13 @@ final class ViewModel: ObservableObject {
215237 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
216238 }
217239
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.
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%🔶`
223247 ///
224248 /// Takes its values as parameters rather than reading the properties: `@Published`
225249 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -231,30 +255,50 @@ final class ViewModel: ObservableObject {
231255 claudeFiveHourPercent: Int?,
232256 claudeSevenDayPercent: Int?,
233257 zaiFiveHourPercent: Int?,
234− zaiWeeklyPercent: Int?
258+ zaiWeeklyPercent: Int?,
259+ zaiInPeakHours: Bool
235260 ) -> String {
236− var parts: [String] = []
261+ var segments: [String] = []
262+
263+ // Hyper
237264 if hyperConfigured, let balance = balance {
238− parts.append("⚡\(formatBalance(balance))")
265+ segments.append("⚡\(formatBalance(balance))")
239266 }
240− if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
241− parts.append("🕐\(fiveHour)%")
267+
268+ // Claude — provider emoji + bare percentages
269+ var claudeWindows: [String] = []
270+ if let fiveHour = claudeFiveHourPercent {
271+ claudeWindows.append("\(fiveHour)%")
242272 }
243− if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
244− parts.append("📅\(sevenDay)%")
273+ if let sevenDay = claudeSevenDayPercent {
274+ claudeWindows.append("\(sevenDay)%")
245275 }
246− if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
247− parts.append("🤖\(zaiFiveHour)%")
276+ if !claudeWindows.isEmpty {
277+ segments.append("⁕" + claudeWindows.joined(separator: " "))
248278 }
249− if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
250− parts.append("📆\(zaiWeekly)%")
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)%")
251287 }
252− if parts.isEmpty {
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 {
253297 // No service is configured or has anything to show. While loading,
254298 // show an ellipsis; otherwise the title is empty.
255299 return isLoading ? "…" : ""
256300 }
257− return parts.joined(separator: " · ")
301+ return segments.joined(separator: " · ")
258302 }
259303
260304 /// The text to show in the menu bar for the current state.
@@ -266,10 +310,17 @@ final class ViewModel: ObservableObject {
266310 claudeFiveHourPercent: claudeFiveHourPercent,
267311 claudeSevenDayPercent: claudeSevenDayPercent,
268312 zaiFiveHourPercent: zaiFiveHourPercent,
269− zaiWeeklyPercent: zaiWeeklyPercent
313+ zaiWeeklyPercent: zaiWeeklyPercent,
314+ zaiInPeakHours: Self.zaiInPeakHours
270315 )
271316 }
272317
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+
273324 /// The formatted balance string for display in the popover.
274325 var formattedBalance: String {
275326 guard let balance = balance else { return "?" }
@@ -290,33 +341,69 @@ final class ViewModel: ObservableObject {
290341 return "Updated \(Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()))"
291342 }
292343
293− // MARK: - History
344+ // MARK: - History (Hyper)
294345
295− /// The last 24 hours of successful fetches, oldest first.
346+ /// The last 24 hours of successful balance fetches, oldest first.
296347 var balanceHistory: [(date: Date, balance: Int)] {
297− history.map { (date: $0.date, balance: $0.balance) }
348+ history.map { (date: $0.date, balance: $0.value) }
298349 }
299350
300351 /// The direction of travel across the most recent handful of observations.
301352 /// `.unknown` until there are at least two points to compare.
302− var balanceTrend: BalanceTrend {
303− guard history.count >= 2 else { return .unknown }
304− let recent = history.suffix(5)
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)
305392 guard let first = recent.first, let last = recent.last else { return .unknown }
306− if last.balance > first.balance { return .increasing }
307− if last.balance < first.balance { return .decreasing }
393+ if last.value > first.value { return .increasing }
394+ if last.value < first.value { return .decreasing }
308395 return .stable
309396 }
310397
311398 /// Appends an observation and drops anything older than 24 hours or beyond the cap.
312− private func recordHistory(balance: Int, at date: Date) {
313− history.append(BalancePoint(date: date, balance: balance))
399+ private func recordMetric(_ value: Int, into array: inout [MetricPoint], at date: Date) {
400+ array.append(MetricPoint(date: date, value: value))
314401
315402 let cutoff = date.addingTimeInterval(-Self.historyWindow)
316− history.removeAll { $0.date < cutoff }
403+ array.removeAll { $0.date < cutoff }
317404
318− if history.count > Self.maxHistoryEntries {
319− history.removeFirst(history.count - Self.maxHistoryEntries)
405+ if array.count > Self.maxHistoryEntries {
406+ array.removeFirst(array.count - Self.maxHistoryEntries)
320407 }
321408 }
322409
@@ -391,7 +478,7 @@ final class ViewModel: ObservableObject {
391478 errorMessage = nil
392479 let now = Date()
393480 lastUpdated = now
394− recordHistory(balance: newBalance, at: now)
481+ recordMetric(newBalance, into: &history, at: now)
395482
396483 // Low-balance notification: on threshold crossing (≥10 → <10)
397484 // OR on first successful fetch if already below threshold.
@@ -436,12 +523,30 @@ final class ViewModel: ObservableObject {
436523 claudeUsage = nil
437524 claudePlan = nil
438525 claudeError = nil
526+ claudeHistory.removeAll()
439527
440528 case .usage(let report):
529+ // Capture the previous 5-hour % for threshold-crossing notification.
530+ let previousPercent = claudeFiveHourPercent
441531 claudeUsage = report.usage
442532 claudePlan = report.subscriptionType
443533 claudeError = nil
444534
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+
445550 case .failure(let message):
446551 // As with the balance: keep the last good numbers on screen and say what
447552 // went wrong, rather than blanking the section on a transient failure.
@@ -509,11 +614,29 @@ final class ViewModel: ObservableObject {
509614 case .notConfigured:
510615 zaiUsage = nil
511616 zaiError = nil
617+ zaiHistory.removeAll()
512618
513619 case .usage(let report):
620+ // Capture the previous 5-hour % for threshold-crossing notification.
621+ let previousPercent = zaiFiveHourPercent
514622 zaiUsage = report
515623 zaiError = nil
516624
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+
517640 case .failure(let message):
518641 // As with the balance and Claude: keep the last good numbers on screen
519642 // and say what went wrong, rather than blanking the section on a
@@ -567,7 +690,41 @@ final class ViewModel: ObservableObject {
567690 }
568691 }
569692
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
571728
572729 private func sendLowBalanceNotification(balance: Int) {
573730 // Build the body here, on the main actor. `getNotificationSettings` calls back on
@@ -596,6 +753,29 @@ final class ViewModel: ObservableObject {
596753 }
597754 }
598755
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+
599779 // MARK: - API Key
600780
601781 /// Saves the current `apiKeyInput` to the Keychain and triggers a refresh.
@@ -678,6 +858,7 @@ final class ViewModel: ObservableObject {
678858 if key != activeZaiAPIKey {
679859 zaiUsage = nil
680860 zaiError = nil
861+ zaiHistory.removeAll()
681862 }
682863 // Show the trimmed key that was actually stored.
683864 zaiAPIKeyInput = key
@@ -715,4 +896,4 @@ final class ViewModel: ObservableObject {
715896 isInitializingLaunchAtLogin = false
716897 }
717898 }
718−}
899+}
UsageMenubarTests/StatusBarTextTests.swift+106−46
@@ -15,9 +15,10 @@ final class StatusBarTextTests: XCTestCase {
1515 balance: 42, isLoading: false,
1616 hyperConfigured: true,
1717 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
18− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
19+ zaiInPeakHours: false
1920 )
20− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
21+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
2122 }
2223
2324 func testHyperOnly() {
@@ -25,7 +26,8 @@ final class StatusBarTextTests: XCTestCase {
2526 balance: 42, isLoading: false,
2627 hyperConfigured: true,
2728 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
28− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
29+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
30+ zaiInPeakHours: false
2931 )
3032 XCTAssertEqual(title, "⚡42")
3133 }
@@ -35,9 +37,10 @@ final class StatusBarTextTests: XCTestCase {
3537 balance: nil, isLoading: false,
3638 hyperConfigured: false,
3739 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
38− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
40+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
41+ zaiInPeakHours: false
3942 )
40− XCTAssertEqual(title, "🕐62% · 📅8%")
43+ XCTAssertEqual(title, "\u{2055}62% 8%")
4144 }
4245
4346 func testLoadingWithNothingToShow() {
@@ -45,7 +48,8 @@ final class StatusBarTextTests: XCTestCase {
4548 balance: nil, isLoading: true,
4649 hyperConfigured: false,
4750 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
48− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
51+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
52+ zaiInPeakHours: false
4953 )
5054 XCTAssertEqual(title, "…")
5155 }
@@ -55,7 +59,8 @@ final class StatusBarTextTests: XCTestCase {
5559 balance: nil, isLoading: false,
5660 hyperConfigured: false,
5761 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
58− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
62+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
63+ zaiInPeakHours: false
5964 )
6065 XCTAssertEqual(title, "")
6166 }
@@ -68,9 +73,10 @@ final class StatusBarTextTests: XCTestCase {
6873 balance: nil, isLoading: true,
6974 hyperConfigured: false,
7075 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
71− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
76+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
77+ zaiInPeakHours: false
7278 )
73− XCTAssertEqual(title, "🕐62%")
79+ XCTAssertEqual(title, "\u{2055}62%")
7480 }
7581
7682 func testLoadingKeepsAStaleBalanceOnScreen() {
@@ -78,19 +84,21 @@ final class StatusBarTextTests: XCTestCase {
7884 balance: 42, isLoading: true,
7985 hyperConfigured: true,
8086 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
81− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
87+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
88+ zaiInPeakHours: false
8289 )
83− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
90+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
8491 }
8592
86− func testZeroPercentIsShownRatherThanOmitted() {
93+ func testZeroPercentIsShown() {
8794 let title = ViewModel.statusBarText(
8895 balance: 42, isLoading: false,
8996 hyperConfigured: true,
9097 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
91− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
98+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
99+ zaiInPeakHours: false
92100 )
93− XCTAssertEqual(title, "⚡42 · 📅8%")
101+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 8%")
94102 }
95103
96104 func testBothClaudeWindowsZero() {
@@ -98,9 +106,10 @@ final class StatusBarTextTests: XCTestCase {
98106 balance: 42, isLoading: false,
99107 hyperConfigured: true,
100108 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
101− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
109+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
110+ zaiInPeakHours: false
102111 )
103− XCTAssertEqual(title, "⚡42")
112+ XCTAssertEqual(title, "⚡42 · \u{2055}0% 0%")
104113 }
105114
106115 func testFiveHourOnly() {
@@ -108,9 +117,10 @@ final class StatusBarTextTests: XCTestCase {
108117 balance: nil, isLoading: false,
109118 hyperConfigured: false,
110119 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
111− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
120+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
121+ zaiInPeakHours: false
112122 )
113− XCTAssertEqual(title, "🕐62%")
123+ XCTAssertEqual(title, "\u{2055}62%")
114124 }
115125
116126 func testSevenDayOnly() {
@@ -118,9 +128,10 @@ final class StatusBarTextTests: XCTestCase {
118128 balance: nil, isLoading: false,
119129 hyperConfigured: false,
120130 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
121− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
131+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
132+ zaiInPeakHours: false
122133 )
123− XCTAssertEqual(title, "📅8%")
134+ XCTAssertEqual(title, "\u{2055}8%")
124135 }
125136
126137 // MARK: - Hyper not configured
@@ -132,7 +143,8 @@ final class StatusBarTextTests: XCTestCase {
132143 balance: 42, isLoading: false,
133144 hyperConfigured: false,
134145 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
135− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
146+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
147+ zaiInPeakHours: false
136148 )
137149 XCTAssertEqual(title, "")
138150 }
@@ -142,7 +154,8 @@ final class StatusBarTextTests: XCTestCase {
142154 balance: nil, isLoading: true,
143155 hyperConfigured: false,
144156 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
145− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
157+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
158+ zaiInPeakHours: false
146159 )
147160 XCTAssertEqual(title, "…")
148161 }
@@ -152,9 +165,10 @@ final class StatusBarTextTests: XCTestCase {
152165 balance: nil, isLoading: false,
153166 hyperConfigured: false,
154167 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
155− zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
168+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
169+ zaiInPeakHours: false
156170 )
157− XCTAssertEqual(title, "🕐62% · 📅8%")
171+ XCTAssertEqual(title, "\u{2055}62% 8%")
158172 }
159173
160174 // MARK: - z.ai in Title
@@ -164,9 +178,10 @@ final class StatusBarTextTests: XCTestCase {
164178 balance: nil, isLoading: false,
165179 hyperConfigured: false,
166180 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
167− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
181+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
182+ zaiInPeakHours: false
168183 )
169− XCTAssertEqual(title, "🤖12%")
184+ XCTAssertEqual(title, "\u{005A}12%")
170185 }
171186
172187 func testZaiWithHyper() {
@@ -174,9 +189,10 @@ final class StatusBarTextTests: XCTestCase {
174189 balance: 42, isLoading: false,
175190 hyperConfigured: true,
176191 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
177− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
192+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
193+ zaiInPeakHours: false
178194 )
179− XCTAssertEqual(title, "⚡42 · 🤖12%")
195+ XCTAssertEqual(title, "⚡42 · \u{005A}12%")
180196 }
181197
182198 func testZaiWithClaude() {
@@ -184,9 +200,10 @@ final class StatusBarTextTests: XCTestCase {
184200 balance: nil, isLoading: false,
185201 hyperConfigured: false,
186202 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
187− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
203+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
204+ zaiInPeakHours: false
188205 )
189− XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
206+ XCTAssertEqual(title, "\u{2055}62% 8% · \u{005A}12%")
190207 }
191208
192209 func testAllThreeServices() {
@@ -194,19 +211,21 @@ final class StatusBarTextTests: XCTestCase {
194211 balance: 42, isLoading: false,
195212 hyperConfigured: true,
196213 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
197− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
214+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
215+ zaiInPeakHours: false
198216 )
199− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12% · 📆3%")
217+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%")
200218 }
201219
202− func testZaiZeroIsOmitted() {
220+ func testZaiZeroIsShown() {
203221 let title = ViewModel.statusBarText(
204222 balance: 42, isLoading: false,
205223 hyperConfigured: true,
206224 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
207− zaiFiveHourPercent: 0, zaiWeeklyPercent: 0
225+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0,
226+ zaiInPeakHours: false
208227 )
209− XCTAssertEqual(title, "⚡42")
228+ XCTAssertEqual(title, "⚡42 · \u{005A}0% 0%")
210229 }
211230
212231 func testLoadingKeepsZaiPercentOnScreen() {
@@ -214,9 +233,10 @@ final class StatusBarTextTests: XCTestCase {
214233 balance: nil, isLoading: true,
215234 hyperConfigured: false,
216235 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
217− zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
236+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
237+ zaiInPeakHours: false
218238 )
219− XCTAssertEqual(title, "🤖12%")
239+ XCTAssertEqual(title, "\u{005A}12%")
220240 }
221241
222242 // MARK: - z.ai Weekly
@@ -226,9 +246,10 @@ final class StatusBarTextTests: XCTestCase {
226246 balance: nil, isLoading: false,
227247 hyperConfigured: false,
228248 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
229− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
249+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
250+ zaiInPeakHours: false
230251 )
231− XCTAssertEqual(title, "📆3%")
252+ XCTAssertEqual(title, "\u{005A}3%")
232253 }
233254
234255 func testZaiWeeklyWithFiveHour() {
@@ -236,19 +257,21 @@ final class StatusBarTextTests: XCTestCase {
236257 balance: nil, isLoading: false,
237258 hyperConfigured: false,
238259 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
239− zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
260+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
261+ zaiInPeakHours: false
240262 )
241− XCTAssertEqual(title, "🤖12% · 📆3%")
263+ XCTAssertEqual(title, "\u{005A}12% 3%")
242264 }
243265
244− func testZaiWeeklyZeroIsOmitted() {
266+ func testZaiWeeklyZeroIsShown() {
245267 let title = ViewModel.statusBarText(
246268 balance: nil, isLoading: false,
247269 hyperConfigured: false,
248270 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249− zaiFiveHourPercent: 12, zaiWeeklyPercent: 0
271+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0,
272+ zaiInPeakHours: false
250273 )
251− XCTAssertEqual(title, "🤖12%")
274+ XCTAssertEqual(title, "\u{005A}12% 0%")
252275 }
253276
254277 func testLoadingKeepsZaiWeeklyOnScreen() {
@@ -256,9 +279,46 @@ final class StatusBarTextTests: XCTestCase {
256279 balance: nil, isLoading: true,
257280 hyperConfigured: false,
258281 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
259− zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
282+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3,
283+ zaiInPeakHours: false
284+ )
285+ XCTAssertEqual(title, "\u{005A}3%")
286+ }
287+
288+ // MARK: - z.ai Peak Hours
289+
290+ func testPeakHoursAddsDiamondToZaiSegment() {
291+ let title = ViewModel.statusBarText(
292+ balance: 42, isLoading: false,
293+ hyperConfigured: true,
294+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
295+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3,
296+ zaiInPeakHours: true
297+ )
298+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8% · \u{005A}12% 3%\u{1F536}")
299+ }
300+
301+ func testPeakHoursWithZaiOnly() {
302+ let title = ViewModel.statusBarText(
303+ balance: nil, isLoading: false,
304+ hyperConfigured: false,
305+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
306+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil,
307+ zaiInPeakHours: true
308+ )
309+ XCTAssertEqual(title, "\u{005A}12%\u{1F536}")
310+ }
311+
312+ func testPeakHoursIgnoredWhenZaiNotConfigured() {
313+ let title = ViewModel.statusBarText(
314+ balance: 42, isLoading: false,
315+ hyperConfigured: true,
316+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
317+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil,
318+ zaiInPeakHours: true
260319 )
261− XCTAssertEqual(title, "📆3%")
320+ // No z.ai segment, so peak marker has nothing to attach to.
321+ XCTAssertEqual(title, "⚡42 · \u{2055}62% 8%")
262322 }
263323
264324 // MARK: - Claude Window Percentages
project.yml+4−4
@@ -6,7 +6,7 @@ name: UsageMenubar
66options:
77 # Reverse-DNS prefix xcodegen uses when it needs to synthesize a bundle id.
88 # The app target pins its own id explicitly below, so this is just a default.
9− bundleIdPrefix: com.ilyakooo0
9+ bundleIdPrefix: soy.iko
1010 # Oldest macOS we support. SMAppService (launch at login) requires 13.0+;
1111 # we target 14.0 (Sonoma). Keep in sync with MACOSX_DEPLOYMENT_TARGET below.
1212 deploymentTarget:
@@ -47,7 +47,7 @@ targets:
4747 properties:
4848 CFBundleName: UsageMenubar
4949 CFBundleDisplayName: Usage Menubar
50− CFBundleIdentifier: com.ilyakooo0.usage-menubar
50+ CFBundleIdentifier: soy.iko.usage-menubar
5151 # Overwritten by CI — see the note on MARKETING_VERSION above.
5252 CFBundleVersion: "1"
5353 CFBundleShortVersionString: "1.0.0"
@@ -57,7 +57,7 @@ targets:
5757 NSHighResolutionCapable: true
5858 settings:
5959 base:
60− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar
60+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar
6161 PRODUCT_NAME: UsageMenubar
6262 INFOPLIST_FILE: UsageMenubar/Info.plist
6363 # Grants the outgoing-network-connection entitlement needed to reach
@@ -84,7 +84,7 @@ targets:
8484 - target: UsageMenubar
8585 settings:
8686 base:
87− PRODUCT_BUNDLE_IDENTIFIER: com.ilyakooo0.usage-menubar.tests
87+ PRODUCT_BUNDLE_IDENTIFIER: soy.iko.usage-menubar.tests
8888 PRODUCT_NAME: UsageMenubarTests
8989 BUNDLE_LOADER: "$(TEST_HOST)"
9090 TEST_HOST: "$(BUILT_PRODUCTS_DIR)/UsageMenubar.app/Contents/MacOS/UsageMenubar"