@@ -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+}