Add Usage Metrics

Nucleic-Session: B4F51091-BE3C-42D3-BCDD-8E89E83543D0
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-09 01:49:14 -07:00
co-authored by nucleic
parent 20e9a84cdb
commit 53e1d26636
+54 -20
View File
@@ -330,39 +330,73 @@ struct QuotaCard: View {
}
}
// MARK: - Codex usage card (placeholder)
// MARK: - Codex usage card
/// A placeholder counterpart to `QuotaCard` for Codex (GPT) subscription limits. Codex has
/// no usage endpoint wired up yet, so this card only reserves the shape: the same card chrome
/// and 5-hour / weekly rows as the Claude card, dimmed, with a "Coming soon" note. When the
/// Codex usage backend lands it can render live windows here exactly like `QuotaCard`.
/// The Codex (ChatGPT-subscription) counterpart to `QuotaCard`: the same card chrome and
/// labeled window bars, reading `AppStore.codexUsage` the rolling limits the `codex`
/// app-server pushes during a turn (`account/rateLimits/updated`). Codex reports two windows
/// (`primary` 5-hour, `secondary` weekly); each renders through the shared `UsageWindowRow`
/// so it reads identically to the Claude card. Until a Codex turn has reported usage there's
/// nothing to poll, so it falls back to a dimmed placeholder with a "no data yet" note.
struct CodexUsageCard: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
/// When > 0, stretch to at least this height (set to the paired card's height so a row of
/// cards lines up mirrors `QuotaCard.matchHeight`).
var matchHeight: CGFloat = 0
var title: String = "Codex"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
Text("Codex").font(.headline)
Spacer()
Text("Coming soon")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(AppTheme.hairline, in: .capsule)
// Re-evaluate every 30s so the "resets in" countdown stays current, matching `QuotaCard`.
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(title).font(.headline)
}
content(now: context.date)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(16)
.frame(minHeight: matchHeight > 0 ? matchHeight : nil, alignment: .top)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
}
@ViewBuilder
private func content(now: Date) -> some View {
if let usage = store.codexUsage, usage.hasData {
VStack(alignment: .leading, spacing: 14) {
windowRow(usage.primary, fallbackLabel: "5-hour limit", icon: QuotaFormat.fiveHourIcon, now: now)
windowRow(usage.secondary, fallbackLabel: "Weekly limit", icon: QuotaFormat.weeklyIcon, now: now)
}
} else {
VStack(alignment: .leading, spacing: 14) {
placeholderRow("5-hour limit", icon: QuotaFormat.fiveHourIcon)
placeholderRow("Weekly limit", icon: QuotaFormat.weeklyIcon)
}
.opacity(0.5)
Text("Codex usage limits will appear here once support lands.")
Text("Codex usage appears once a Codex chat reports it.")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(16)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
/// A dimmed, dataless mirror of `QuotaCard.windowRow`: the label and an empty bar, with the
/// One live Codex window; renders nothing for an absent window. Labels itself from the
/// window's reported length when codex sends one, else the positional fallback.
@ViewBuilder
private func windowRow(
_ window: CodexUsageWindow?, fallbackLabel: String, icon: String, now: Date
) -> some View {
if let window {
UsageWindowRow(
label: window.label ?? fallbackLabel, icon: icon,
utilization: window.utilization(at: now),
resetCaption: QuotaFormat.resetCaption(window.resetsAt, now: now), palette: palette)
}
}
/// A dimmed, dataless mirror of `UsageWindowRow`: the label and an empty bar, with the
/// percentage shown as "" so the row's shape reads without implying a real value.
private func placeholderRow(_ label: String, icon: String) -> some View {
VStack(alignment: .leading, spacing: 4) {