diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e4d42a0..d60eabf 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -3375,7 +3375,8 @@ final class RemoteStore: ObservableObject { dashboard = DashboardSnapshot( counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects, todos: transform(dashboard.todos), usage: dashboard.usage, - codexUsage: dashboard.codexUsage, statusFeeds: dashboard.statusFeeds) + codexUsage: dashboard.codexUsage, grokUsage: dashboard.grokUsage, + statusFeeds: dashboard.statusFeeds) } @@ -3458,6 +3459,7 @@ extension DashboardSnapshot { let usage = snaps.compactMap(\.usage).first let codexUsage = snaps.compactMap(\.codexUsage).first + let grokUsage = snaps.compactMap(\.grokUsage).first var seenProviders = Set() var statusFeeds: [WireStatusFeed] = [] @@ -3469,7 +3471,8 @@ extension DashboardSnapshot { return DashboardSnapshot( counts: counts, activity: activity, projects: projects, - todos: todos, usage: usage, codexUsage: codexUsage, statusFeeds: statusFeeds) + todos: todos, usage: usage, codexUsage: codexUsage, grokUsage: grokUsage, + statusFeeds: statusFeeds) } } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index afa4d6c..e0db3e6 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -82,9 +82,11 @@ struct HomeView: View { statCards // The Mac's usage gauges (5-hour / weekly windows); hidden when the host - // doesn't project usage. Codex's card follows, shown once a Codex chat reports. + // doesn't project usage. Codex's card follows, shown once a Codex chat + // reports, then Grok's once the host's poll finds a grok login. QuotaCard(usage: store.dashboard.usage) CodexQuotaCard(usage: store.dashboard.codexUsage) + GrokQuotaCard(usage: store.dashboard.grokUsage) VStack(alignment: .leading, spacing: 10) { Text("Activity").font(.headline) diff --git a/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift b/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift index c1b42cf..62afa33 100644 --- a/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift +++ b/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift @@ -10,6 +10,9 @@ import NucleicProtocol enum QuotaFormat { static let fiveHourIcon = "clock" static let weeklyIcon = "calendar" + /// Grok bills a credit allowance rather than a time window, so its rows read as credits. + static let creditsIcon = "creditcard" + static let onDemandIcon = "plus.forwardslash.minus" static func percent(_ value: Double) -> String { "\(Int(value.rounded()))%" } @@ -168,6 +171,72 @@ struct CodexQuotaCard: View { } } +// MARK: - Grok quota card (the Mac's GrokUsageCard, dashboard form) + +/// The Grok counterpart to `QuotaCard`, rendered from `DashboardSnapshot.grokUsage` — the +/// billing period's included credit allowance the host polls from grok's chat proxy. Grok bills +/// credits rather than time windows, so the primary row is the period's allowance; the +/// on-demand overage row joins it only for accounts that have on-demand billing enabled with a +/// cap. Renders nothing until the host has a grok login to poll, so Claude-only users never +/// carry an empty card. Same thresholds, icons, and copy as the Mac's card. +struct GrokQuotaCard: View { + let usage: GrokUsage? + + var body: some View { + if let usage { + TimelineView(.periodic(from: .now, by: 30)) { context in + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary) + Text("Grok").font(.headline) + } + row(label: usage.cycleLabel, icon: QuotaFormat.creditsIcon, + utilization: usage.utilization(at: context.date), + resetsAt: usage.resetsAt, now: context.date) + if let onDemand = usage.onDemandUtilization(at: context.date) { + row(label: "On-demand", icon: QuotaFormat.onDemandIcon, + utilization: onDemand, + resetsAt: usage.resetsAt, now: context.date) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .card() + } + } + + private func row( + label: String, icon: String, utilization: Double, resetsAt: Date?, now: Date + ) -> some View { + let color = QuotaFormat.color(forPercent: utilization) + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 5) { + Image(systemName: icon).font(.caption).foregroundStyle(.secondary) + Text(label).font(.subheadline) + Spacer() + Text(QuotaFormat.percent(utilization)) + .font(.subheadline.monospacedDigit().weight(.semibold)) + .foregroundStyle(color) + } + bar(fraction: utilization / 100, color: color) + if let caption = QuotaFormat.resetCaption(resetsAt, now: now) { + Text(caption).font(.caption2).foregroundStyle(.secondary) + } + } + } + + private func bar(fraction: Double, color: Color) -> some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.18)) + Capsule().fill(color) + .frame(width: max(3, geo.size.width * min(1, max(0, fraction)))) + } + } + .frame(height: 6) + } +} + // MARK: - Status pill (the Mac's StatusFeedIndicator) /// A compact service-status pill: a calm green check while every watched provider is