259 lines
10 KiB
Swift
259 lines
10 KiB
Swift
import SwiftUI
|
|
import NucleicProtocol
|
|
|
|
/// The Mac's usage gauges and provider-status pill, rendered from the dashboard's wire
|
|
/// projections (`DashboardSnapshot.usage` / `.statusFeeds`) — same thresholds, icons, and
|
|
/// copy as `QuotaIndicator` / `StatusFeedIndicator` on desktop.
|
|
|
|
// MARK: - Shared formatting (mirrors the Mac's QuotaFormat)
|
|
|
|
enum QuotaFormat {
|
|
static let fiveHourIcon = "clock"
|
|
static let weeklyIcon = "calendar"
|
|
|
|
static func percent(_ value: Double) -> String { "\(Int(value.rounded()))%" }
|
|
|
|
/// "6d 23h" / "2h 14m" / "14m" — the Mac's countdown formatting.
|
|
static func duration(_ interval: TimeInterval) -> String {
|
|
let total = Int(interval)
|
|
let days = total / 86_400
|
|
let hours = (total % 86_400) / 3600
|
|
let minutes = (total % 3600) / 60
|
|
if days > 0 { return "\(days)d \(hours)h" }
|
|
if hours > 0 { return "\(hours)h \(minutes)m" }
|
|
if minutes > 0 { return "\(minutes)m" }
|
|
return "<1m"
|
|
}
|
|
|
|
static func resetCaption(_ window: WireUsageWindow, now: Date) -> String? {
|
|
resetCaption(window.resetsAt, now: now)
|
|
}
|
|
|
|
static func resetCaption(_ resetsAt: Date?, now: Date) -> String? {
|
|
guard let resetsAt else { return nil }
|
|
let remaining = resetsAt.timeIntervalSince(now)
|
|
guard remaining > 0 else { return "resetting…" }
|
|
return "resets in \(duration(remaining))"
|
|
}
|
|
|
|
/// Calm until 75%, amber to 90%, red beyond — the Mac's escalation.
|
|
static func color(forPercent value: Double) -> Color {
|
|
switch value {
|
|
case ..<75: return Palette.accent
|
|
case ..<90: return Palette.attention
|
|
default: return Palette.danger
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Quota card (the Mac's QuotaCard, dashboard form)
|
|
|
|
/// Labeled progress bars for the 5-hour and weekly windows with reset countdowns.
|
|
/// Renders nothing when the host hasn't projected usage (older host, logged out) so the
|
|
/// home screen doesn't carry a permanently empty card.
|
|
struct QuotaCard: View {
|
|
let usage: WireSubscriptionUsage?
|
|
|
|
var body: some View {
|
|
if let usage, usage.peakUtilization != nil {
|
|
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("Usage").font(.headline)
|
|
}
|
|
windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: context.date)
|
|
windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: context.date)
|
|
windowRow("Weekly · Fable", icon: "sparkles", usage.sevenDayFable, now: context.date)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.card()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func windowRow(_ label: String, icon: String, _ window: WireUsageWindow?, now: Date) -> some View {
|
|
if let window {
|
|
let utilization = window.utilization(at: now)
|
|
let color = QuotaFormat.color(forPercent: utilization)
|
|
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(window, 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: - Codex quota card (the Mac's CodexUsageCard, dashboard form)
|
|
|
|
/// The Codex counterpart to `QuotaCard`, rendered from `DashboardSnapshot.codexUsage` — the
|
|
/// rolling weekly window the host's `codex` agent reported (its short ~5-hour window has been
|
|
/// retired). Renders nothing until a Codex chat has reported usage, so Claude-only users never
|
|
/// carry an empty card. Same thresholds, icons, and copy as the Claude card.
|
|
struct CodexQuotaCard: View {
|
|
let usage: CodexUsage?
|
|
|
|
var body: some View {
|
|
if let usage, usage.hasData {
|
|
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("Codex").font(.headline)
|
|
}
|
|
windowRow(usage.weekly, fallbackLabel: "Weekly limit",
|
|
icon: QuotaFormat.weeklyIcon, now: context.date)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.card()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func windowRow(
|
|
_ window: CodexUsageWindow?, fallbackLabel: String, icon: String, now: Date
|
|
) -> some View {
|
|
if let window {
|
|
let utilization = window.utilization(at: now)
|
|
let color = QuotaFormat.color(forPercent: utilization)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
|
|
Text(window.label ?? fallbackLabel).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(window.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
|
|
/// operational, amber naming the affected providers when any has an active incident.
|
|
/// Tapping opens a sheet listing current incidents with links to the status pages.
|
|
/// Renders nothing before the host projects any feed (older host / first poll pending).
|
|
struct StatusFeedPill: View {
|
|
let feeds: [WireStatusFeed]
|
|
@State private var showingSheet = false
|
|
|
|
private var active: [WireStatusIncident] { feeds.flatMap(\.activeIncidents) }
|
|
private var affected: [String] { feeds.filter(\.hasActiveIncident).map(\.providerName) }
|
|
|
|
var body: some View {
|
|
if !feeds.isEmpty {
|
|
Button { showingSheet = true } label: {
|
|
if active.isEmpty {
|
|
Label("Operational", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(Palette.success)
|
|
} else {
|
|
Label(affected.joined(separator: ", "), systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(Palette.attention)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.caption)
|
|
.sheet(isPresented: $showingSheet) { StatusFeedSheet(feeds: feeds) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The pill's detail: current incidents grouped by provider (the Mac popover, sheet-form).
|
|
private struct StatusFeedSheet: View {
|
|
let feeds: [WireStatusFeed]
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(feeds) { feed in
|
|
Section(feed.providerName) {
|
|
if feed.activeIncidents.isEmpty {
|
|
Label("Operational", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(Palette.success)
|
|
.font(.subheadline)
|
|
} else {
|
|
ForEach(feed.activeIncidents) { incident in
|
|
incidentRow(incident)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Service status")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func incidentRow(_ incident: WireStatusIncident) -> some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(incident.title).font(.subheadline.weight(.medium))
|
|
HStack(spacing: 6) {
|
|
if let state = incident.state {
|
|
Text(state).font(.caption).foregroundStyle(Palette.attention)
|
|
}
|
|
if !incident.components.isEmpty {
|
|
Text(incident.components.joined(separator: " · "))
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if let updated = incident.updatedAt {
|
|
Text(updated.formatted(date: .omitted, time: .shortened))
|
|
.font(.caption).foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
if let url = incident.url {
|
|
Link("Status page", destination: url).font(.caption)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|