mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
The Codex plan tab reused the shared connect, no-credentials and reconnect views whose copy was hardcoded for Claude, so the Codex tab showed Connect Claude subscription. Worse, the no-credentials Try Again button always re-ran the Claude bootstrap. The three views now take title, message and action from the call site. The oauth usage endpoint gained a limits array carrying model-scoped weekly buckets (currently Fable). Parse weekly_scoped entries generically by their display name and show them in the Claude hover popover and the plan tab, with the usual pace projection. Parser now has test coverage using the captured live response shape.
2226 lines
86 KiB
Swift
2226 lines
86 KiB
Swift
import SwiftUI
|
|
|
|
private let trendChartHeight: CGFloat = 90
|
|
|
|
// Cached formatters and a calendar to avoid allocating fresh ones on every
|
|
// SwiftUI body re-eval. Hover scrubbing on the trend bars triggers many
|
|
// re-evals per second; a fresh DateFormatter / Calendar each time was a
|
|
// measurable hot spot.
|
|
private let yyyymmdd: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = .current
|
|
return f
|
|
}()
|
|
|
|
private let prettyDayFormat: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "EEE MMM d"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
return f
|
|
}()
|
|
|
|
private let mmmDayFormat: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "MMM d"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = .current
|
|
return f
|
|
}()
|
|
|
|
private let gregorianCalendar: Calendar = {
|
|
var c = Calendar(identifier: .gregorian)
|
|
c.timeZone = .current
|
|
return c
|
|
}()
|
|
|
|
/// Switchable insight visualizations: trend, calendar, forecast, pulse, stats,
|
|
/// optimize, plus provider-specific plan views.
|
|
struct HeatmapSection: View {
|
|
@Environment(AppStore.self) private var store
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
InsightPillSwitcher(selected: bindingMode, visibleModes: visibleModes)
|
|
content
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onAppear { ensureValidSelection() }
|
|
.onChange(of: store.selectedProvider) { _, _ in ensureValidSelection() }
|
|
}
|
|
|
|
private var bindingMode: Binding<InsightMode> {
|
|
Binding(get: { store.selectedInsight }, set: { store.selectedInsight = $0 })
|
|
}
|
|
|
|
private var visibleModes: [InsightMode] {
|
|
// Plan sources from a provider's OAuth usage endpoint. Currently
|
|
// implemented for Claude (Anthropic) and Codex (ChatGPT). Hidden on
|
|
// All / Cursor / Droid / Gemini / Copilot until those providers ship
|
|
// their own quota data sources.
|
|
InsightMode.allCases.filter { mode in
|
|
if mode == .plan {
|
|
return store.selectedProvider == .claude || store.selectedProvider == .codex
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func ensureValidSelection() {
|
|
if !visibleModes.contains(store.selectedInsight) {
|
|
store.selectedInsight = visibleModes.first ?? .trend
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
switch store.selectedInsight {
|
|
case .plan:
|
|
if store.selectedProvider == .codex {
|
|
CodexPlanInsight()
|
|
} else {
|
|
PlanInsight(usage: store.subscription)
|
|
}
|
|
case .trend: TrendInsight(days: store.payload.history.daily, period: store.trendPeriod)
|
|
case .calendar: ContributionHeatmapInsight(days: store.payload.history.daily)
|
|
case .forecast: ForecastInsight(days: store.payload.history.daily)
|
|
case .pulse: PulseInsight(payload: store.payload)
|
|
case .stats: StatsInsight(payload: store.payload)
|
|
case .optimize: OptimizeInsight(payload: store.payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Pill Switcher
|
|
|
|
private struct InsightPillSwitcher: View {
|
|
@Binding var selected: InsightMode
|
|
let visibleModes: [InsightMode]
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 4) {
|
|
ForEach(visibleModes) { mode in
|
|
Button {
|
|
selected = mode
|
|
} label: {
|
|
Text(mode.rawValue)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.fixedSize()
|
|
.foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10)))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Trend (14-day bar chart with peak + average)
|
|
|
|
private struct TrendInsight: View {
|
|
let days: [DailyHistoryEntry]
|
|
let period: Period
|
|
|
|
private var trendDayCount: Int {
|
|
switch period {
|
|
case .today, .sevenDays: return 19
|
|
case .thirtyDays: return 30
|
|
case .month: return 31
|
|
case .all: return min(days.count, 90)
|
|
}
|
|
}
|
|
|
|
private var barGap: CGFloat {
|
|
trendDayCount > 45 ? 2 : 4
|
|
}
|
|
|
|
var body: some View {
|
|
let dayCount = trendDayCount
|
|
let bars = buildTrendBars(from: days, dayCount: dayCount)
|
|
let stats = computeTrendStats(bars: bars, allDays: days, dayCount: dayCount)
|
|
// Tokens are real for the .all-providers view; per-provider history doesn't carry
|
|
// token breakdown yet, so fall back to $ when no tokens are present.
|
|
let totalTokens = bars.reduce(0.0) { $0 + $1.tokens }
|
|
let useTokens = totalTokens > 0
|
|
let metric: (TrendBar) -> Double = useTokens ? { $0.tokens } : { $0.cost }
|
|
let maxValue = max(bars.map(metric).max() ?? 1, 0.01)
|
|
let avgValue = bars.isEmpty ? 0 : bars.map(metric).reduce(0, +) / Double(bars.count)
|
|
let peakValue = bars.filter({ metric($0) > 0 }).max(by: { metric($0) < metric($1) })
|
|
let yesterdayValue = stats.yesterdayBar.map(metric)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("Last \(dayCount) days")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow))
|
|
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.primary)
|
|
}
|
|
Spacer()
|
|
if let delta = stats.deltaPercent {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right")
|
|
.font(.system(size: 9, weight: .bold))
|
|
Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(dayCount)d")
|
|
.font(.system(size: 10.5))
|
|
.monospacedDigit()
|
|
}
|
|
.foregroundStyle(Theme.brandAccent)
|
|
}
|
|
}
|
|
|
|
TrendChart(
|
|
bars: bars,
|
|
maxValue: maxValue,
|
|
avgValue: avgValue,
|
|
metric: metric,
|
|
formatValue: { formatValue($0, useTokens: useTokens) },
|
|
barGap: barGap
|
|
)
|
|
.zIndex(1)
|
|
|
|
HStack(spacing: 14) {
|
|
MiniStat(label: "Avg/day", value: formatValue(avgValue, useTokens: useTokens))
|
|
MiniStat(label: "Peak", value: peakLabel(peakValue, metric: metric, useTokens: useTokens))
|
|
MiniStat(label: "Yesterday", value: yesterdayValue.map { formatValue($0, useTokens: useTokens) } ?? "—")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatHero(useTokens: Bool, tokens: Double, dollars: Double) -> String {
|
|
useTokens ? "\(formatTokens(tokens)) tokens" : dollars.asCurrency()
|
|
}
|
|
|
|
private func formatValue(_ v: Double, useTokens: Bool) -> String {
|
|
useTokens ? "\(formatTokens(v)) tok" : v.asCompactCurrency()
|
|
}
|
|
|
|
private func peakLabel(_ peak: TrendBar?, metric: (TrendBar) -> Double, useTokens: Bool) -> String {
|
|
guard let peak, metric(peak) > 0 else { return "—" }
|
|
return "\(formatValue(metric(peak), useTokens: useTokens)) on \(shortDate(peak.date))"
|
|
}
|
|
|
|
private func formatTokens(_ n: Double) -> String {
|
|
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
|
|
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
|
|
return String(format: "%.0f", n)
|
|
}
|
|
|
|
private func shortDate(_ ymd: String) -> String {
|
|
let parts = ymd.split(separator: "-")
|
|
guard parts.count == 3 else { return ymd }
|
|
return "\(parts[1])/\(parts[2])"
|
|
}
|
|
}
|
|
|
|
private struct TrendChart: View {
|
|
let bars: [TrendBar]
|
|
let maxValue: Double
|
|
let avgValue: Double
|
|
let metric: (TrendBar) -> Double
|
|
let formatValue: (Double) -> String
|
|
let barGap: CGFloat
|
|
|
|
@State private var hoveredBarID: TrendBar.ID?
|
|
|
|
private var peakBarID: TrendBar.ID? {
|
|
bars.filter { metric($0) > 0 }.max(by: { metric($0) < metric($1) })?.id
|
|
}
|
|
|
|
var body: some View {
|
|
let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0
|
|
|
|
ZStack(alignment: .bottomLeading) {
|
|
HStack(alignment: .bottom, spacing: barGap) {
|
|
ForEach(bars) { bar in
|
|
BarColumn(
|
|
bar: bar,
|
|
value: metric(bar),
|
|
maxValue: maxValue,
|
|
isHovered: hoveredBarID == bar.id,
|
|
isPeak: bar.id == peakBarID
|
|
)
|
|
.onHover { hovering in
|
|
hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.frame(height: trendChartHeight, alignment: .bottom)
|
|
|
|
GeometryReader { geo in
|
|
Path { p in
|
|
let y = geo.size.height - (geo.size.height * avgFraction)
|
|
p.move(to: CGPoint(x: 0, y: y))
|
|
p.addLine(to: CGPoint(x: geo.size.width, y: y))
|
|
}
|
|
.stroke(Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
|
}
|
|
.frame(height: trendChartHeight)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.frame(height: trendChartHeight)
|
|
.overlay(alignment: .bottomLeading) {
|
|
if let hoveredBar {
|
|
BarTooltipCard(bar: hoveredBar, value: metric(hoveredBar), formatValue: formatValue)
|
|
.padding(.top, 6)
|
|
.offset(y: 92)
|
|
.transition(.opacity)
|
|
.allowsHitTesting(false)
|
|
.zIndex(10)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.12), value: hoveredBarID)
|
|
}
|
|
|
|
private var hoveredBar: TrendBar? {
|
|
guard let id = hoveredBarID else { return nil }
|
|
return bars.first { $0.id == id }
|
|
}
|
|
}
|
|
|
|
private struct BarColumn: View {
|
|
let bar: TrendBar
|
|
let value: Double
|
|
let maxValue: Double
|
|
let isHovered: Bool
|
|
let isPeak: Bool
|
|
|
|
var body: some View {
|
|
let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0
|
|
let height = max(2, trendChartHeight * fraction)
|
|
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: 0)
|
|
if isPeak && value > 0 {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(Color.yellow.opacity(0.85))
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: max(2, trendChartHeight * 0.05))
|
|
}
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(barColor)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: height)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1)
|
|
)
|
|
.scaleEffect(x: isHovered ? 1.08 : 1.0, y: 1.0, anchor: .bottom)
|
|
.animation(.easeOut(duration: 0.12), value: isHovered)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
private var barColor: Color {
|
|
if bar.isToday { return Theme.brandAccent }
|
|
if value <= 0 { return Color.secondary.opacity(0.15) }
|
|
if isHovered { return Theme.brandAccent.opacity(0.85) }
|
|
let ratio = maxValue > 0 ? value / maxValue : 0
|
|
return Theme.brandAccent.opacity(0.42 + ratio * 0.48)
|
|
}
|
|
}
|
|
|
|
private struct BarTooltipCard: View {
|
|
let bar: TrendBar
|
|
/// Value to display in the tooltip header. Matches the metric the trend chart
|
|
/// is currently using (tokens when the .all-providers view has token data,
|
|
/// cost when provider-filtered views force a $ fallback). Passing this in keeps
|
|
/// the tooltip in sync with the chart instead of always reading bar.tokens,
|
|
/// which is zero for provider-filtered days.
|
|
let value: Double
|
|
let formatValue: (Double) -> String
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private var backgroundFill: Color {
|
|
colorScheme == .dark ? Color.white : Color.black
|
|
}
|
|
|
|
private var primaryText: Color {
|
|
colorScheme == .dark ? Color.black : Color.white
|
|
}
|
|
|
|
private var secondaryText: Color {
|
|
colorScheme == .dark ? Color.black.opacity(0.7) : Color.white.opacity(0.72)
|
|
}
|
|
|
|
private var tertiaryText: Color {
|
|
colorScheme == .dark ? Color.black.opacity(0.5) : Color.white.opacity(0.52)
|
|
}
|
|
|
|
private var borderStroke: Color {
|
|
colorScheme == .dark ? Color.black.opacity(0.12) : Color.white.opacity(0.12)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(prettyDate(bar.date))
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(primaryText)
|
|
Spacer()
|
|
Text("\(formatValue(value))")
|
|
.font(.codeMono(size: 10.5, weight: .semibold))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
}
|
|
|
|
if !bar.topModels.isEmpty {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.offset) { idx, m in
|
|
HStack(spacing: 6) {
|
|
RoundedRectangle(cornerRadius: 1)
|
|
.fill(Theme.brandAccent.opacity(0.75 - Double(idx) * 0.12))
|
|
.frame(width: 3, height: 12)
|
|
Text(m.name)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(primaryText)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if m.cost > 0 {
|
|
Text(m.cost.asCompactCurrency())
|
|
.font(.codeMono(size: 9.5, weight: .semibold))
|
|
.foregroundStyle(secondaryText)
|
|
}
|
|
Text("\(formatTokensCompact(Double(m.totalTokens))) tok")
|
|
.font(.codeMono(size: 9.5, weight: .medium))
|
|
.foregroundStyle(tertiaryText)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(11)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(backgroundFill)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(borderStroke, lineWidth: 0.5)
|
|
)
|
|
.shadow(color: Color.black.opacity(0.35), radius: 10, y: 4)
|
|
}
|
|
|
|
private func formatTokensCompact(_ n: Double) -> String {
|
|
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
|
|
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
|
|
return String(format: "%.0f", n)
|
|
}
|
|
}
|
|
|
|
private func prettyDate(_ ymd: String) -> String {
|
|
guard let date = yyyymmdd.date(from: ymd) else { return ymd }
|
|
return prettyDayFormat.string(from: date)
|
|
}
|
|
|
|
private struct MiniStat: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(value)
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 5)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color(nsColor: .separatorColor).opacity(0.35))
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct TrendBar: Identifiable {
|
|
var id: String { date }
|
|
let date: String
|
|
let cost: Double
|
|
let inputTokens: Double
|
|
let outputTokens: Double
|
|
let isToday: Bool
|
|
let topModels: [DailyModelBreakdown]
|
|
|
|
var tokens: Double { inputTokens + outputTokens }
|
|
}
|
|
|
|
private struct TrendStats {
|
|
let totalThisWindow: Double
|
|
let avgPerDay: Double
|
|
let peak: TrendBar?
|
|
let activeDays: Int
|
|
let deltaPercent: Double?
|
|
let yesterdayBar: TrendBar?
|
|
}
|
|
|
|
private func buildTrendBars(from days: [DailyHistoryEntry], dayCount: Int) -> [TrendBar] {
|
|
let calendar = gregorianCalendar
|
|
let formatter = yyyymmdd
|
|
let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new })
|
|
let today = calendar.startOfDay(for: Date())
|
|
let todayKey = formatter.string(from: today)
|
|
|
|
var bars: [TrendBar] = []
|
|
for offset in (0..<dayCount).reversed() {
|
|
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { continue }
|
|
let key = formatter.string(from: d)
|
|
let entry = entryByDate[key]
|
|
bars.append(TrendBar(
|
|
date: key,
|
|
cost: entry?.cost ?? 0,
|
|
inputTokens: Double(entry?.inputTokens ?? 0),
|
|
outputTokens: Double(entry?.outputTokens ?? 0),
|
|
isToday: key == todayKey,
|
|
topModels: entry?.topModels ?? []
|
|
))
|
|
}
|
|
return bars
|
|
}
|
|
|
|
private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry], dayCount: Int) -> TrendStats {
|
|
let total = bars.reduce(0.0) { $0 + $1.cost }
|
|
let active = bars.filter { $0.cost > 0 }.count
|
|
let avg = bars.isEmpty ? 0 : total / Double(bars.count)
|
|
let peak = bars.filter { $0.cost > 0 }.max(by: { $0.cost < $1.cost })
|
|
|
|
let calendar = gregorianCalendar
|
|
let formatter = yyyymmdd
|
|
let today = calendar.startOfDay(for: Date())
|
|
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * dayCount - 1), to: today)
|
|
let thisWindowStart = calendar.date(byAdding: .day, value: -(dayCount - 1), to: today)
|
|
var deltaPercent: Double? = nil
|
|
if let priorStart = priorWindowStart, let thisStart = thisWindowStart {
|
|
let priorStartStr = formatter.string(from: priorStart)
|
|
let thisStartStr = formatter.string(from: thisStart)
|
|
let priorTotal = allDays
|
|
.filter { $0.date >= priorStartStr && $0.date < thisStartStr }
|
|
.reduce(0.0) { $0 + $1.cost }
|
|
if priorTotal > 0 {
|
|
deltaPercent = ((total - priorTotal) / priorTotal) * 100
|
|
}
|
|
}
|
|
|
|
let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: today)
|
|
let yesterdayKey = yesterdayDate.map { formatter.string(from: $0) }
|
|
let yesterdayBar = bars.first(where: { $0.date == yesterdayKey })
|
|
|
|
return TrendStats(
|
|
totalThisWindow: total,
|
|
avgPerDay: avg,
|
|
peak: peak,
|
|
activeDays: active,
|
|
deltaPercent: deltaPercent,
|
|
yesterdayBar: yesterdayBar
|
|
)
|
|
}
|
|
|
|
// MARK: - Calendar
|
|
|
|
private struct ContributionHeatmapInsight: View {
|
|
let days: [DailyHistoryEntry]
|
|
|
|
private let cellSize: CGFloat = 8
|
|
private let cellGap: CGFloat = 3
|
|
private let weekdayLabelWidth: CGFloat = 26
|
|
@State private var hoveredDayID: ContributionDay.ID?
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let weekCount = adaptiveWeekCount(for: geo.size.width)
|
|
let weeks = buildContributionWeeks(from: days, weekCount: weekCount)
|
|
let stats = computeContributionStats(weeks: weeks)
|
|
let hoveredDay = weeks.flatMap(\.days).first { $0.id == hoveredDayID }
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("Daily activity")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(stats.total.asCurrency())
|
|
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.primary)
|
|
}
|
|
Spacer()
|
|
Text("\(stats.activeDays) active days")
|
|
.font(.system(size: 10.5, weight: .medium))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Theme.brandAccent)
|
|
}
|
|
|
|
HStack(alignment: .top, spacing: 6) {
|
|
VStack(alignment: .trailing, spacing: cellGap) {
|
|
ForEach(0..<7, id: \.self) { idx in
|
|
Text(weekdayLabel(for: idx))
|
|
.font(.system(size: 8.5, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
.frame(width: weekdayLabelWidth, height: cellSize, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .top, spacing: cellGap) {
|
|
ForEach(weeks) { week in
|
|
VStack(spacing: cellGap) {
|
|
ForEach(week.days) { day in
|
|
ContributionCell(
|
|
day: day,
|
|
size: cellSize,
|
|
isHovered: hoveredDayID == day.id
|
|
)
|
|
.onHover { hovering in
|
|
hoveredDayID = hovering ? day.id : (hoveredDayID == day.id ? nil : hoveredDayID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
|
|
ContributionDayDetail(day: hoveredDay, fallbackStats: stats)
|
|
.animation(.easeInOut(duration: 0.12), value: hoveredDayID)
|
|
|
|
HStack(spacing: 14) {
|
|
MiniStat(label: "Peak day", value: stats.peakLabel)
|
|
MiniStat(label: "Avg active", value: stats.avgActive.asCompactCurrency())
|
|
MiniStat(label: "Streak", value: "\(stats.currentStreak)d")
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 216)
|
|
}
|
|
|
|
private func adaptiveWeekCount(for width: CGFloat) -> Int {
|
|
let available = max(0, width - weekdayLabelWidth - 10)
|
|
let raw = Int(floor((available + cellGap) / (cellSize + cellGap)))
|
|
return min(max(raw, 1), 52)
|
|
}
|
|
|
|
private func weekdayLabel(for index: Int) -> String {
|
|
switch index {
|
|
case 0: return "Mon"
|
|
case 2: return "Wed"
|
|
case 4: return "Fri"
|
|
case 6: return "Sun"
|
|
default: return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ContributionCell: View {
|
|
let day: ContributionDay
|
|
let size: CGFloat
|
|
let isHovered: Bool
|
|
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(fillColor)
|
|
.frame(width: size, height: size)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.stroke(strokeColor, lineWidth: isHovered || day.isToday ? 1 : 0)
|
|
)
|
|
.scaleEffect(isHovered ? 1.35 : 1.0)
|
|
.animation(.easeOut(duration: 0.10), value: isHovered)
|
|
.help(day.helpText)
|
|
.accessibilityLabel(day.helpText)
|
|
}
|
|
|
|
private var strokeColor: Color {
|
|
if isHovered { return Theme.brandAccent.opacity(0.95) }
|
|
if day.isToday { return Theme.brandAccent.opacity(0.95) }
|
|
return Color.clear
|
|
}
|
|
|
|
private var fillColor: Color {
|
|
if day.isFuture { return Color.secondary.opacity(0.06) }
|
|
if isHovered { return Theme.brandAccent.opacity(0.95) }
|
|
switch day.level {
|
|
case 0: return Color.secondary.opacity(0.14)
|
|
case 1: return Theme.brandAccent.opacity(0.30)
|
|
case 2: return Theme.brandAccent.opacity(0.48)
|
|
case 3: return Theme.brandAccent.opacity(0.66)
|
|
default: return Theme.brandAccent.opacity(0.88)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ContributionDayDetail: View {
|
|
let day: ContributionDay?
|
|
let fallbackStats: ContributionStats
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(value)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
DetailMetric(label: "Calls", value: calls)
|
|
DetailMetric(label: "Tokens", value: tokens)
|
|
}
|
|
.padding(.horizontal, 9)
|
|
.padding(.vertical, 7)
|
|
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color(nsColor: .separatorColor).opacity(0.25))
|
|
)
|
|
}
|
|
|
|
private var title: String {
|
|
// The header already shows the period total and active-day count, so
|
|
// the resting state is a short hover hint — not a duplicate of those
|
|
// (and not the full sentence that previously overflowed and truncated).
|
|
guard let day else { return "Daily detail" }
|
|
return prettyDate(day.date)
|
|
}
|
|
|
|
private var value: String {
|
|
guard let day else { return "Hover a day" }
|
|
if day.isFuture { return "Future day" }
|
|
if day.cost <= 0 && day.calls == 0 { return "No tracked usage" }
|
|
return day.cost.asCompactCurrency()
|
|
}
|
|
|
|
private var calls: String {
|
|
guard let day, !day.isFuture else { return "—" }
|
|
return "\(day.calls)"
|
|
}
|
|
|
|
private var tokens: String {
|
|
guard let day, !day.isFuture else { return "—" }
|
|
return formatTokensForContribution(day.totalTokens)
|
|
}
|
|
}
|
|
|
|
private struct DetailMetric: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(label)
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(value)
|
|
.font(.codeMono(size: 11, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(minWidth: 42, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
struct ContributionWeek: Identifiable, Equatable {
|
|
let startDate: String
|
|
let days: [ContributionDay]
|
|
|
|
var id: String { startDate }
|
|
}
|
|
|
|
struct ContributionDay: Identifiable, Equatable {
|
|
let date: String
|
|
let cost: Double
|
|
let calls: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let level: Int
|
|
let isToday: Bool
|
|
let isFuture: Bool
|
|
|
|
var id: String { date }
|
|
var totalTokens: Int { inputTokens + outputTokens }
|
|
|
|
@MainActor var helpText: String {
|
|
if isFuture { return "\(prettyDate(date)): future day" }
|
|
if cost <= 0 && calls == 0 { return "\(prettyDate(date)): no tracked usage" }
|
|
return "\(prettyDate(date)): \(cost.asCompactCurrency()), \(calls) calls, \(formatTokensForContribution(totalTokens)) tokens"
|
|
}
|
|
}
|
|
|
|
struct ContributionStats: Equatable {
|
|
let total: Double
|
|
let activeDays: Int
|
|
let avgActive: Double
|
|
let peakLabel: String
|
|
let currentStreak: Int
|
|
}
|
|
|
|
func contributionLevel(value: Double, maxValue: Double) -> Int {
|
|
guard value > 0, maxValue > 0 else { return 0 }
|
|
let ratio = min(max(value / maxValue, 0), 1)
|
|
if ratio < 0.25 { return 1 }
|
|
if ratio < 0.50 { return 2 }
|
|
if ratio < 0.75 { return 3 }
|
|
return 4
|
|
}
|
|
|
|
func buildContributionWeeks(
|
|
from days: [DailyHistoryEntry],
|
|
weekCount: Int,
|
|
now: Date = Date(),
|
|
calendar: Calendar = gregorianCalendar,
|
|
formatter: DateFormatter = yyyymmdd
|
|
) -> [ContributionWeek] {
|
|
let today = calendar.startOfDay(for: now)
|
|
let todayKey = formatter.string(from: today)
|
|
let visibleWeekCount = min(max(weekCount, 1), 52)
|
|
let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new })
|
|
guard
|
|
let thisWeekStart = startOfContributionWeek(containing: today, calendar: calendar),
|
|
let firstWeekStart = calendar.date(byAdding: .weekOfYear, value: -(visibleWeekCount - 1), to: thisWeekStart)
|
|
else {
|
|
return []
|
|
}
|
|
|
|
var visibleKeys: [String] = []
|
|
for offset in 0..<(visibleWeekCount * 7) {
|
|
guard let date = calendar.date(byAdding: .day, value: offset, to: firstWeekStart) else { continue }
|
|
if date <= today { visibleKeys.append(formatter.string(from: date)) }
|
|
}
|
|
let maxCost = visibleKeys.compactMap { entryByDate[$0]?.cost }.max() ?? 0
|
|
|
|
var weeks: [ContributionWeek] = []
|
|
for weekOffset in 0..<visibleWeekCount {
|
|
guard let weekStart = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: firstWeekStart) else { continue }
|
|
let weekStartKey = formatter.string(from: weekStart)
|
|
var contributionDays: [ContributionDay] = []
|
|
|
|
for dayOffset in 0..<7 {
|
|
guard let date = calendar.date(byAdding: .day, value: dayOffset, to: weekStart) else { continue }
|
|
let key = formatter.string(from: date)
|
|
let entry = entryByDate[key]
|
|
let isFuture = date > today
|
|
let cost = isFuture ? 0 : (entry?.cost ?? 0)
|
|
contributionDays.append(ContributionDay(
|
|
date: key,
|
|
cost: cost,
|
|
calls: isFuture ? 0 : (entry?.calls ?? 0),
|
|
inputTokens: isFuture ? 0 : (entry?.inputTokens ?? 0),
|
|
outputTokens: isFuture ? 0 : (entry?.outputTokens ?? 0),
|
|
level: isFuture ? 0 : contributionLevel(value: cost, maxValue: maxCost),
|
|
isToday: key == todayKey,
|
|
isFuture: isFuture
|
|
))
|
|
}
|
|
|
|
weeks.append(ContributionWeek(startDate: weekStartKey, days: contributionDays))
|
|
}
|
|
|
|
return weeks
|
|
}
|
|
|
|
@MainActor func computeContributionStats(weeks: [ContributionWeek]) -> ContributionStats {
|
|
let days = weeks.flatMap(\.days).filter { !$0.isFuture }
|
|
let active = days.filter { $0.cost > 0 }
|
|
let total = active.reduce(0.0) { $0 + $1.cost }
|
|
let avg = active.isEmpty ? 0 : total / Double(active.count)
|
|
let peak = active.max(by: { $0.cost < $1.cost })
|
|
let peakLabel = peak.map { "\($0.cost.asCompactCurrency()) on \(shortContributionDate($0.date))" } ?? "—"
|
|
|
|
var streak = 0
|
|
for day in days.reversed() {
|
|
if day.cost > 0 {
|
|
streak += 1
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return ContributionStats(
|
|
total: total,
|
|
activeDays: active.count,
|
|
avgActive: avg,
|
|
peakLabel: peakLabel,
|
|
currentStreak: streak
|
|
)
|
|
}
|
|
|
|
private func startOfContributionWeek(containing date: Date, calendar: Calendar) -> Date? {
|
|
let start = calendar.startOfDay(for: date)
|
|
let weekday = calendar.component(.weekday, from: start)
|
|
let daysFromMonday = (weekday + 5) % 7
|
|
return calendar.date(byAdding: .day, value: -daysFromMonday, to: start)
|
|
}
|
|
|
|
private func shortContributionDate(_ ymd: String) -> String {
|
|
guard let date = yyyymmdd.date(from: ymd) else { return ymd }
|
|
return mmmDayFormat.string(from: date)
|
|
}
|
|
|
|
private func formatTokensForContribution(_ n: Int) -> String {
|
|
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
|
|
if n >= 1_000 { return String(format: "%.0fK", Double(n) / 1_000) }
|
|
return "\(n)"
|
|
}
|
|
|
|
// MARK: - Forecast
|
|
|
|
private struct ForecastInsight: View {
|
|
let days: [DailyHistoryEntry]
|
|
|
|
var body: some View {
|
|
let stats = computeForecast(days: days)
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Month-to-date")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(stats.mtd.asCurrency())
|
|
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Theme.brandAccent)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("On pace for")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(stats.projection.asCurrency())
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 14) {
|
|
ForecastStat(label: "Avg/day (this wk)", value: stats.weekAvg.asCompactCurrency())
|
|
ForecastStat(label: "Yesterday", value: stats.yesterday.asCompactCurrency())
|
|
ForecastStat(label: "Last 7d", value: stats.weekTotal.asCompactCurrency())
|
|
}
|
|
|
|
if let prevTotal = stats.previousMonthTotal {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: stats.projection > prevTotal ? "arrow.up.right" : "arrow.down.right")
|
|
.font(.system(size: 9, weight: .bold))
|
|
Text(comparisonText(projection: stats.projection, previous: prevTotal))
|
|
.font(.system(size: 10.5))
|
|
.monospacedDigit()
|
|
}
|
|
.foregroundStyle(Theme.brandAccent)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func comparisonText(projection: Double, previous: Double) -> String {
|
|
guard previous > 0 else { return "no prior month" }
|
|
let diff = ((projection - previous) / previous) * 100
|
|
let sign = diff >= 0 ? "+" : ""
|
|
return "\(sign)\(String(format: "%.0f", diff))% vs last month (\(previous.asCompactCurrency()))"
|
|
}
|
|
}
|
|
|
|
private struct ForecastStat: View {
|
|
let label: String
|
|
let value: String
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(label)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(value)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct ForecastStats {
|
|
let mtd: Double
|
|
let projection: Double
|
|
let weekAvg: Double
|
|
let weekTotal: Double
|
|
let yesterday: Double
|
|
let previousMonthTotal: Double?
|
|
}
|
|
|
|
private func computeForecast(days: [DailyHistoryEntry]) -> ForecastStats {
|
|
let calendar = gregorianCalendar
|
|
let formatter = yyyymmdd
|
|
let now = Date()
|
|
let comps = calendar.dateComponents([.year, .month, .day], from: now)
|
|
guard
|
|
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
|
|
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
|
|
else {
|
|
return ForecastStats(mtd: 0, projection: 0, weekAvg: 0, weekTotal: 0, yesterday: 0, previousMonthTotal: nil)
|
|
}
|
|
|
|
let firstStr = formatter.string(from: firstOfMonth)
|
|
let totalDays = rangeOfMonth.count
|
|
let dayOfMonth = comps.day ?? 1
|
|
|
|
let mtdEntries = days.filter { $0.date >= firstStr }
|
|
let mtd = mtdEntries.reduce(0.0) { $0 + $1.cost }
|
|
let avgPerElapsedDay = dayOfMonth > 0 ? mtd / Double(dayOfMonth) : 0
|
|
let projection = avgPerElapsedDay * Double(totalDays)
|
|
|
|
let weekStart = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: now))
|
|
let weekStartStr = weekStart.map { formatter.string(from: $0) } ?? ""
|
|
let weekEntries = days.filter { $0.date >= weekStartStr }
|
|
let weekTotal = weekEntries.reduce(0.0) { $0 + $1.cost }
|
|
let weekAvg = weekTotal / 7.0
|
|
|
|
let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
|
|
let yesterdayStr = yesterdayDate.map { formatter.string(from: $0) } ?? ""
|
|
let yesterday = days.first(where: { $0.date == yesterdayStr })?.cost ?? 0
|
|
|
|
var previousMonthTotal: Double? = nil
|
|
if
|
|
let prevMonthDate = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
|
|
let prevRange = calendar.range(of: .day, in: .month, for: prevMonthDate),
|
|
let prevFirst = calendar.date(from: DateComponents(year: calendar.component(.year, from: prevMonthDate), month: calendar.component(.month, from: prevMonthDate), day: 1)),
|
|
let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
|
|
{
|
|
let prevFirstStr = formatter.string(from: prevFirst)
|
|
let prevLastStr = formatter.string(from: prevLast)
|
|
let prevEntries = days.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
|
|
if !prevEntries.isEmpty {
|
|
previousMonthTotal = prevEntries.reduce(0.0) { $0 + $1.cost }
|
|
}
|
|
}
|
|
|
|
return ForecastStats(
|
|
mtd: mtd,
|
|
projection: projection,
|
|
weekAvg: weekAvg,
|
|
weekTotal: weekTotal,
|
|
yesterday: yesterday,
|
|
previousMonthTotal: previousMonthTotal
|
|
)
|
|
}
|
|
|
|
// MARK: - Pulse
|
|
|
|
private struct PulseInsight: View {
|
|
let payload: MenubarPayload
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 10) {
|
|
PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
|
|
PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
|
|
PulseTile(
|
|
label: "Cost / session",
|
|
value: payload.current.sessions > 0
|
|
? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
|
|
: "—",
|
|
color: .secondary
|
|
)
|
|
}
|
|
CostPerEditCaption(models: payload.current.modelEfficiency)
|
|
}
|
|
}
|
|
|
|
private var cacheHitText: String {
|
|
let v = payload.current.cacheHitPercent
|
|
return v <= 0 ? "—" : String(format: "%.0f%%", v)
|
|
}
|
|
|
|
private var oneShotText: String {
|
|
guard let r = payload.current.oneShotRate else { return "—" }
|
|
return String(format: "%.0f%%", r * 100)
|
|
}
|
|
|
|
private var oneShotColor: Color {
|
|
payload.current.oneShotRate == nil ? .secondary : Theme.brandAccent
|
|
}
|
|
}
|
|
|
|
private struct PulseTile: View {
|
|
let label: String
|
|
let value: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(label)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(value)
|
|
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(color)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.secondary.opacity(0.06))
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct CostPerEditCaption: View {
|
|
let models: [ModelEfficiencyEntry]
|
|
|
|
var body: some View {
|
|
let valid = models.compactMap { m -> (String, Double)? in
|
|
guard let cpe = m.costPerEdit, cpe > 0 else { return nil }
|
|
return (m.name, cpe)
|
|
}.sorted(by: { $0.1 < $1.1 })
|
|
|
|
if let best = valid.first {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "pencil.line")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text("Cost/edit")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(formatCPE(best.1))
|
|
.font(.codeMono(size: 10.5, weight: .semibold))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
Text(best.0)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
if valid.count > 1, let worst = valid.last, worst.0 != best.0 {
|
|
Text("—")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.quaternary)
|
|
Text(formatCPE(worst.1))
|
|
.font(.codeMono(size: 10.5, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
Text(worst.0)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatCPE(_ v: Double) -> String {
|
|
if v < 0.01 { return String(format: "$%.3f", v) }
|
|
return String(format: "$%.2f", v)
|
|
}
|
|
}
|
|
|
|
/// Connects optimize findings directly to plan utilization: "address N findings to recover X
|
|
/// tokens" framed as the same currency the rest of the Plan view uses (effective tokens).
|
|
/// Scoped to whatever period the user selected (today / 7d / 30d / month / all).
|
|
private struct OptimizeSavingsBadge: View {
|
|
let payload: MenubarPayload
|
|
|
|
var body: some View {
|
|
let findingCount = payload.optimize.findingCount
|
|
let savingsUSD = payload.optimize.savingsUSD
|
|
if findingCount == 0 || savingsUSD <= 0 {
|
|
EmptyView()
|
|
} else {
|
|
Button { openOptimize() } label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "lightbulb.fill")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
Text(captionText(findingCount: findingCount, savingsUSD: savingsUSD))
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 8, weight: .semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 7)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Theme.brandAccent.opacity(0.10))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
|
|
private func captionText(findingCount: Int, savingsUSD: Double) -> String {
|
|
let tokens = savingsUSD / 9.0 * 1_000_000 // ~$9/M effective tokens (Sonnet-weighted approx)
|
|
let tokensLabel = formatTokens(tokens)
|
|
let plural = findingCount == 1 ? "finding" : "findings"
|
|
return "Save ~\(savingsUSD.asCompactCurrency()) / ~\(tokensLabel) tokens · \(findingCount) \(plural)"
|
|
}
|
|
|
|
private func openOptimize() {
|
|
TerminalLauncher.open(subcommand: ["optimize"])
|
|
}
|
|
|
|
private func formatTokens(_ n: Double) -> String {
|
|
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
|
|
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
|
|
return String(format: "%.0f", n)
|
|
}
|
|
}
|
|
|
|
// MARK: - Stats
|
|
|
|
private struct StatsInsight: View {
|
|
let payload: MenubarPayload
|
|
|
|
var body: some View {
|
|
let stats = computeAllStats(payload: payload)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
StatRow(label: "Favorite model", value: stats.favoriteModel)
|
|
StatRow(label: "Active days (month)", value: stats.activeDaysFraction)
|
|
StatRow(label: "Most active day", value: stats.mostActiveDay)
|
|
StatRow(label: "Peak day spend", value: stats.peakDaySpend)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
StatRow(label: "Sessions today", value: "\(payload.current.sessions)")
|
|
StatRow(label: "Calls today", value: payload.current.calls.asThousandsSeparated())
|
|
StatRow(label: "Current streak", value: stats.currentStreak)
|
|
StatRow(label: "Longest streak", value: stats.longestStreak)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if let lifetime = stats.lifetimeTotal {
|
|
Divider().opacity(0.5)
|
|
HStack {
|
|
Text("Tracked spend (last \(stats.historyDayCount) days)")
|
|
.font(.system(size: 10.5, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
Text(lifetime.asCurrency())
|
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Theme.brandAccent)
|
|
}
|
|
}
|
|
|
|
if !payload.current.topProjects.isEmpty {
|
|
Divider().opacity(0.5)
|
|
TopProjectsList(projects: payload.current.topProjects)
|
|
}
|
|
|
|
if let top = payload.current.topSessions.first, top.cost > 0 {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "flame")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
Text("Costliest session")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
Text(top.cost.asCompactCurrency())
|
|
.font(.codeMono(size: 10.5, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
Text("· \(projectDisplayName(top.project))")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RetryTaxSection: View {
|
|
let retryTax: RetryTax
|
|
let totalCost: Double
|
|
@State private var expanded = false
|
|
|
|
var body: some View {
|
|
if retryTax.totalUSD > 0 {
|
|
Divider().opacity(0.5)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.2.squarepath")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundStyle(.orange)
|
|
Text("Retry tax")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
Text(retryTax.totalUSD.asCompactCurrency())
|
|
.font(.codeMono(size: 11, weight: .bold))
|
|
.foregroundStyle(.orange)
|
|
.monospacedDigit()
|
|
if totalCost > 0 {
|
|
Text("(\(Int((retryTax.totalUSD / totalCost * 100).rounded()))%)")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.quaternary)
|
|
.rotationEffect(.degrees(expanded ? 90 : 0))
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
|
expanded.toggle()
|
|
}
|
|
}
|
|
|
|
Text("\(retryTax.retries) retries across \(retryTax.editTurns) edits")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.quaternary)
|
|
|
|
if expanded {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(Array(retryTax.byModel.enumerated()), id: \.offset) { idx, model in
|
|
HStack(spacing: 0) {
|
|
Text(model.name)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
if let rpe = model.retriesPerEdit {
|
|
Text(String(format: "%.1f ret/edit", rpe))
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.quaternary)
|
|
.padding(.trailing, 8)
|
|
}
|
|
Text(model.taxUSD.asCompactCurrency())
|
|
.font(.codeMono(size: 10, weight: .semibold))
|
|
.foregroundStyle(.orange.opacity(0.85))
|
|
.monospacedDigit()
|
|
}
|
|
.padding(.vertical, 2)
|
|
.padding(.horizontal, 6)
|
|
.background(RoundedRectangle(cornerRadius: 4).fill(.orange.opacity(0.05)))
|
|
.transition(
|
|
.asymmetric(
|
|
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
|
|
removal: .opacity.animation(.easeOut(duration: 0.12))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct StatRow: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(label)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(value)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func projectDisplayName(_ path: String) -> String {
|
|
path.split(separator: "/").last.map(String.init) ?? path
|
|
}
|
|
|
|
private struct TopProjectsList: View {
|
|
let projects: [ProjectEntry]
|
|
@State private var expanded: String?
|
|
|
|
var body: some View {
|
|
let top = Array(projects.prefix(3))
|
|
let maxCost = top.first?.cost ?? 1
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
ForEach(Array(top.enumerated()), id: \.offset) { idx, project in
|
|
let expandKey = "\(idx):\(project.name)"
|
|
let isOpen = expanded == expandKey
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.quaternary)
|
|
.rotationEffect(.degrees(isOpen ? 90 : 0))
|
|
Text(projectDisplayName(project.name))
|
|
.font(.system(size: 10.5, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Text("\(project.sessions) sess")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.quaternary)
|
|
Text(project.cost.asCompactCurrency())
|
|
.font(.codeMono(size: 10.5, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(Theme.brandAccent.opacity(0.5))
|
|
.frame(
|
|
width: max(2, 40 * CGFloat(project.cost / max(maxCost, 0.01))),
|
|
height: 6
|
|
)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
|
expanded = isOpen ? nil : expandKey
|
|
}
|
|
}
|
|
|
|
if isOpen, !project.sessionDetails.isEmpty {
|
|
SessionDetailsList(sessions: project.sessionDetails)
|
|
.padding(.top, 6)
|
|
.padding(.leading, 14)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SessionDetailsList: View {
|
|
let sessions: [SessionDetailEntry]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(Array(sessions.prefix(5).enumerated()), id: \.offset) { idx, sess in
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 0) {
|
|
Text(sess.cost.asCompactCurrency())
|
|
.font(.codeMono(size: 10, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.monospacedDigit()
|
|
.frame(width: 52, alignment: .trailing)
|
|
Text(" \(sess.calls) calls")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.quaternary)
|
|
Spacer()
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "arrow.down")
|
|
.font(.system(size: 7, weight: .semibold))
|
|
Text(compactTokens(sess.inputTokens))
|
|
}
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.tertiary)
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "arrow.up")
|
|
.font(.system(size: 7, weight: .semibold))
|
|
Text(compactTokens(sess.outputTokens))
|
|
}
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.leading, 4)
|
|
}
|
|
HStack(spacing: 4) {
|
|
ForEach(Array(sess.models.prefix(3).enumerated()), id: \.offset) { _, model in
|
|
Text(model.name)
|
|
.font(.system(size: 8.5, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 1.5)
|
|
.background(Theme.brandAccent.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
.padding(.leading, 52)
|
|
}
|
|
.padding(.vertical, 3)
|
|
.padding(.horizontal, 6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 5)
|
|
.fill(.primary.opacity(0.03))
|
|
)
|
|
.transition(
|
|
.asymmetric(
|
|
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
|
|
removal: .opacity.animation(.easeOut(duration: 0.15))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func compactTokens(_ n: Int) -> String {
|
|
let d = Double(n)
|
|
if d >= 1_000_000 { return String(format: "%.1fM", d / 1_000_000) }
|
|
if d >= 1_000 { return String(format: "%.0fK", d / 1_000) }
|
|
return "\(n)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Optimize
|
|
|
|
private struct OptimizeInsight: View {
|
|
let payload: MenubarPayload
|
|
|
|
var body: some View {
|
|
let totalWaste = payload.current.retryTax.totalUSD + payload.current.routingWaste.totalSavingsUSD
|
|
let cost = payload.current.cost
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if totalWaste > 0, cost > 0 {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Potential savings")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Text(totalWaste.asCompactCurrency())
|
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("\(Int((totalWaste / cost * 100).rounded()))% of spend")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.orange.opacity(0.8))
|
|
Text("could be optimized")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.quaternary)
|
|
}
|
|
}
|
|
.padding(.bottom, 2)
|
|
}
|
|
|
|
RetryTaxSection(retryTax: payload.current.retryTax, totalCost: cost)
|
|
|
|
RoutingWasteSection(routingWaste: payload.current.routingWaste, totalCost: cost)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RoutingWasteSection: View {
|
|
let routingWaste: RoutingWaste
|
|
let totalCost: Double
|
|
@State private var expanded = false
|
|
|
|
var body: some View {
|
|
if routingWaste.totalSavingsUSD > 0 {
|
|
Divider().opacity(0.5)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.triangle.swap")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundStyle(.purple)
|
|
Text("Routing waste")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
Text(routingWaste.totalSavingsUSD.asCompactCurrency())
|
|
.font(.codeMono(size: 11, weight: .bold))
|
|
.foregroundStyle(.purple)
|
|
.monospacedDigit()
|
|
if totalCost > 0 {
|
|
Text("(\(Int((routingWaste.totalSavingsUSD / totalCost * 100).rounded()))%)")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.quaternary)
|
|
.rotationEffect(.degrees(expanded ? 90 : 0))
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
|
expanded.toggle()
|
|
}
|
|
}
|
|
|
|
if !routingWaste.baselineModel.isEmpty {
|
|
Text("vs \(routingWaste.baselineModel) @ \(routingWaste.baselineCostPerEdit.asCompactCurrency())/edit")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.quaternary)
|
|
}
|
|
|
|
if expanded {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(Array(routingWaste.byModel.enumerated()), id: \.offset) { idx, model in
|
|
HStack(spacing: 0) {
|
|
Text(model.name)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(String(format: "$%.2f/edit", model.costPerEdit))
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.quaternary)
|
|
.padding(.trailing, 8)
|
|
Text(model.savingsUSD.asCompactCurrency())
|
|
.font(.codeMono(size: 10, weight: .semibold))
|
|
.foregroundStyle(.purple.opacity(0.85))
|
|
.monospacedDigit()
|
|
}
|
|
.padding(.vertical, 2)
|
|
.padding(.horizontal, 6)
|
|
.background(RoundedRectangle(cornerRadius: 4).fill(.purple.opacity(0.05)))
|
|
.transition(
|
|
.asymmetric(
|
|
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
|
|
removal: .opacity.animation(.easeOut(duration: 0.12))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AllStats {
|
|
let favoriteModel: String
|
|
let activeDaysFraction: String
|
|
let mostActiveDay: String
|
|
let peakDaySpend: String
|
|
let currentStreak: String
|
|
let longestStreak: String
|
|
let lifetimeTotal: Double?
|
|
let historyDayCount: Int
|
|
}
|
|
|
|
@MainActor private func computeAllStats(payload: MenubarPayload) -> AllStats {
|
|
let history = payload.history.daily
|
|
let favoriteModel = payload.current.topModels.first?.name ?? "—"
|
|
|
|
let calendar = gregorianCalendar
|
|
let formatter = yyyymmdd
|
|
let displayFormatter = mmmDayFormat
|
|
|
|
let now = Date()
|
|
let today = calendar.startOfDay(for: now)
|
|
let comps = calendar.dateComponents([.year, .month, .day], from: now)
|
|
|
|
var activeDaysFraction = "—"
|
|
if
|
|
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
|
|
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
|
|
{
|
|
let firstStr = formatter.string(from: firstOfMonth)
|
|
let mtdActive = history.filter { $0.date >= firstStr && $0.cost > 0 }.count
|
|
activeDaysFraction = "\(mtdActive)/\(rangeOfMonth.count)"
|
|
}
|
|
|
|
let peak = history.max(by: { $0.cost < $1.cost })
|
|
let mostActiveDay: String
|
|
let peakDaySpend: String
|
|
if let peak, peak.cost > 0, let date = formatter.date(from: peak.date) {
|
|
mostActiveDay = displayFormatter.string(from: date)
|
|
peakDaySpend = peak.cost.asCompactCurrency()
|
|
} else {
|
|
mostActiveDay = "—"
|
|
peakDaySpend = "—"
|
|
}
|
|
|
|
let costByDate = Dictionary(history.map { ($0.date, $0.cost) }, uniquingKeysWith: +)
|
|
|
|
var currentStreak = 0
|
|
for offset in 0..<400 {
|
|
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
|
|
let key = formatter.string(from: d)
|
|
if (costByDate[key] ?? 0) > 0 { currentStreak += 1 } else { break }
|
|
}
|
|
|
|
var longestStreak = 0
|
|
var running = 0
|
|
if let firstDate = history.map(\.date).min(),
|
|
let lastDate = history.map(\.date).max(),
|
|
let start = formatter.date(from: firstDate),
|
|
let end = formatter.date(from: lastDate) {
|
|
var cursor = start
|
|
while cursor <= end {
|
|
let key = formatter.string(from: cursor)
|
|
if (costByDate[key] ?? 0) > 0 {
|
|
running += 1
|
|
longestStreak = max(longestStreak, running)
|
|
} else {
|
|
running = 0
|
|
}
|
|
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
|
cursor = next
|
|
}
|
|
}
|
|
|
|
let lifetimeTotal: Double? = history.isEmpty ? nil : history.reduce(0.0) { $0 + $1.cost }
|
|
|
|
return AllStats(
|
|
favoriteModel: favoriteModel,
|
|
activeDaysFraction: activeDaysFraction,
|
|
mostActiveDay: mostActiveDay,
|
|
peakDaySpend: peakDaySpend,
|
|
currentStreak: currentStreak == 0 ? "—" : "\(currentStreak) days",
|
|
longestStreak: longestStreak == 0 ? "—" : "\(longestStreak) days",
|
|
lifetimeTotal: lifetimeTotal,
|
|
historyDayCount: history.count
|
|
)
|
|
}
|
|
|
|
// MARK: - Plan (subscription)
|
|
|
|
private struct PlanInsight: View {
|
|
@Environment(AppStore.self) private var store
|
|
let usage: SubscriptionUsage?
|
|
|
|
private static let fiveHourSeconds: TimeInterval = 5 * 3600
|
|
private static let sevenDaySeconds: TimeInterval = 7 * 86400
|
|
private static let freshWindowThreshold: Double = 0.05
|
|
|
|
@State private var projections: [String: WindowProjection] = [:]
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch store.subscriptionLoadState {
|
|
case .notBootstrapped, .dormant:
|
|
PlanConnectView(
|
|
title: "Connect Claude subscription",
|
|
message: "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically."
|
|
) { Task { await store.bootstrapSubscription() } }
|
|
case .bootstrapping:
|
|
PlanLoadingView()
|
|
case .loading:
|
|
if let usage {
|
|
loadedBody(usage: usage)
|
|
} else {
|
|
PlanLoadingView()
|
|
}
|
|
case .noCredentials:
|
|
PlanNoCredentialsView(
|
|
title: "No Claude credentials found",
|
|
message: "Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again."
|
|
) { Task { await store.bootstrapSubscription() } }
|
|
case .failed:
|
|
PlanFailedView(error: store.subscriptionError)
|
|
case .transientFailure:
|
|
if let usage {
|
|
loadedBody(usage: usage)
|
|
} else {
|
|
PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.")
|
|
}
|
|
case let .terminalFailure(reason):
|
|
PlanReconnectView(
|
|
title: "Reconnect Claude",
|
|
reason: reason,
|
|
fallback: "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect."
|
|
) { Task { await store.bootstrapSubscription() } }
|
|
case .loaded:
|
|
if let usage {
|
|
loadedBody(usage: usage)
|
|
} else {
|
|
PlanLoadingView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func loadedBody(usage: SubscriptionUsage) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(usage.tier.displayName)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
Spacer()
|
|
if let resets = headlineReset(usage: usage) {
|
|
Text("Resets \(resets)")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 8) {
|
|
if let p = usage.fiveHourPercent {
|
|
UtilizationRow(label: "5-hour window", percent: p, resetsAt: usage.fiveHourResetsAt, projection: projections["five_hour"])
|
|
}
|
|
if let p = usage.sevenDayPercent {
|
|
UtilizationRow(label: "7-day total", percent: p, resetsAt: usage.sevenDayResetsAt, projection: projections["seven_day"])
|
|
}
|
|
if let p = usage.sevenDayOpusPercent {
|
|
UtilizationRow(label: "7-day Opus", percent: p, resetsAt: usage.sevenDayOpusResetsAt, projection: projections["seven_day_opus"])
|
|
}
|
|
if let p = usage.sevenDaySonnetPercent {
|
|
UtilizationRow(label: "7-day Sonnet", percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"])
|
|
}
|
|
ForEach(usage.scopedWeekly, id: \.label) { scoped in
|
|
UtilizationRow(label: "7-day \(scoped.label)", percent: scoped.percent, resetsAt: scoped.resetsAt, projection: projections["scoped_\(scoped.label)"])
|
|
}
|
|
}
|
|
|
|
OptimizeSavingsBadge(payload: store.payload)
|
|
}
|
|
.task(id: usage.fetchedAt) {
|
|
await recomputeProjections(usage: usage)
|
|
}
|
|
}
|
|
|
|
private func recomputeProjections(usage: SubscriptionUsage) async {
|
|
var result: [String: WindowProjection] = [:]
|
|
var inputs: [(String, Double?, Date?, TimeInterval)] = [
|
|
("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, Self.fiveHourSeconds),
|
|
("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt, Self.sevenDaySeconds),
|
|
("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, Self.sevenDaySeconds),
|
|
("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, Self.sevenDaySeconds),
|
|
]
|
|
for scoped in usage.scopedWeekly {
|
|
inputs.append(("scoped_\(scoped.label)", scoped.percent, scoped.resetsAt, Self.sevenDaySeconds))
|
|
}
|
|
for (key, percent, resetsAt, windowSeconds) in inputs {
|
|
if let projection = await project(key: key, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) {
|
|
result[key] = projection
|
|
}
|
|
}
|
|
projections = result
|
|
}
|
|
|
|
/// Linear extrapolation when window is past the freshness threshold; otherwise falls back to
|
|
/// the prior cycle's final percent from the snapshot store.
|
|
private func project(key: String, percent: Double?, resetsAt: Date?, windowSeconds: TimeInterval) async -> WindowProjection? {
|
|
guard let percent, let resetsAt else { return nil }
|
|
let windowStart = resetsAt.addingTimeInterval(-windowSeconds)
|
|
let elapsed = Date().timeIntervalSince(windowStart)
|
|
let elapsedFraction = elapsed / windowSeconds
|
|
|
|
if elapsedFraction > Self.freshWindowThreshold, percent > 0 {
|
|
let projectedPercent = percent / elapsedFraction
|
|
var hitDate: Date? = nil
|
|
if projectedPercent > 100, percent < 100 {
|
|
let remainingPercent = 100 - percent
|
|
let percentPerSecond = percent / elapsed
|
|
if percentPerSecond > 0 {
|
|
hitDate = Date().addingTimeInterval(remainingPercent / percentPerSecond)
|
|
}
|
|
}
|
|
return WindowProjection(percent: projectedPercent, willOverflow: projectedPercent > 100, hitsLimitAt: hitDate, source: .linear)
|
|
}
|
|
|
|
// Window too fresh OR percent exactly zero -- use the prior cycle's final reading.
|
|
if let prior = await SubscriptionSnapshotStore.previousWindowFinal(windowKey: key, currentResetsAt: resetsAt) {
|
|
return WindowProjection(percent: prior, willOverflow: prior > 100, hitsLimitAt: nil, source: .historicalBaseline)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func headlineReset(usage: SubscriptionUsage) -> String? {
|
|
let candidates = [
|
|
usage.fiveHourResetsAt,
|
|
usage.sevenDayResetsAt,
|
|
usage.sevenDayOpusResetsAt,
|
|
usage.sevenDaySonnetResetsAt,
|
|
].compactMap { $0 }
|
|
guard let earliest = candidates.min() else { return nil }
|
|
return relativeReset(earliest)
|
|
}
|
|
}
|
|
|
|
// MARK: - Plan empty/loading/failure states
|
|
|
|
private struct PlanLoadingView: View {
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
ProgressView().scaleEffect(0.8)
|
|
Text("Reading Claude credentials...")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
}
|
|
}
|
|
|
|
private struct PlanNoCredentialsView: View {
|
|
let title: String
|
|
let message: String
|
|
let onRetry: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "key.slash")
|
|
.font(.system(size: 24))
|
|
.foregroundStyle(.tertiary)
|
|
Text(title)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
Text(.init(message))
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: 280)
|
|
Button("Try Again", action: onRetry)
|
|
.controlSize(.small)
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(Theme.brandAccent)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
}
|
|
}
|
|
|
|
private struct PlanFailedView: View {
|
|
@Environment(AppStore.self) private var store
|
|
let error: String?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 18))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
Text("Couldn't load plan data")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
if let error {
|
|
Text(error)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.tertiary)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: 280)
|
|
.lineLimit(3)
|
|
}
|
|
Button("Retry") {
|
|
Task { await store.refreshSubscription() }
|
|
}
|
|
.controlSize(.small)
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(Theme.brandAccent)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
}
|
|
|
|
/// Shown the very first time a user opens the Plan tab. Clicking Connect is the
|
|
/// only path to triggering the provider's credential read (for Claude, the
|
|
/// macOS keychain prompt) — the menubar app does not touch credentials at
|
|
/// startup.
|
|
private struct PlanConnectView: View {
|
|
let title: String
|
|
let message: String
|
|
let onConnect: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "link.circle")
|
|
.font(.system(size: 26))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
Text(title)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
Text(.init(message))
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: 280)
|
|
Button("Connect", action: onConnect)
|
|
.controlSize(.small)
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(Theme.brandAccent)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 18)
|
|
}
|
|
}
|
|
|
|
/// Shown when the refresh token has been invalidated (typically because the user
|
|
/// re-authenticated on another device). Clicking the button re-runs bootstrap,
|
|
/// which reads the provider's credentials source again and writes a fresh copy
|
|
/// to our own keychain item.
|
|
private struct PlanReconnectView: View {
|
|
let title: String
|
|
let reason: String?
|
|
let fallback: String
|
|
let onReconnect: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "arrow.triangle.2.circlepath.circle")
|
|
.font(.system(size: 24))
|
|
.foregroundStyle(.red)
|
|
Text(title)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
Text(reason ?? fallback)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: 280)
|
|
.lineLimit(3)
|
|
Button("Reconnect", action: onReconnect)
|
|
.controlSize(.small)
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.red)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
}
|
|
}
|
|
|
|
/// Plan tab for Codex. Mirrors PlanInsight's layout but reads from
|
|
/// store.codexUsage / store.codexLoadState. We deliberately skip the
|
|
/// "On pace at reset" projection here — that math is fed by local
|
|
/// per-message Claude spend extrapolated against the API quota windows;
|
|
/// our local Codex spend isn't an apples-to-apples signal for the
|
|
/// ChatGPT-subscription rate windows reported by wham/usage. Add when
|
|
/// we wire a comparable extrapolator.
|
|
private struct CodexPlanInsight: View {
|
|
@Environment(AppStore.self) private var store
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch store.codexLoadState {
|
|
case .notBootstrapped, .dormant:
|
|
PlanConnectView(
|
|
title: "Connect ChatGPT subscription",
|
|
message: "CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically."
|
|
) { Task { await store.bootstrapCodex() } }
|
|
case .bootstrapping:
|
|
PlanLoadingView()
|
|
case .loading:
|
|
if let usage = store.codexUsage {
|
|
loadedBody(usage: usage)
|
|
} else {
|
|
PlanLoadingView()
|
|
}
|
|
case .noCredentials:
|
|
PlanNoCredentialsView(
|
|
title: "No Codex credentials found",
|
|
message: "Sign in with Codex first: run `codex login` in your terminal. Then click Try Again."
|
|
) { Task { await store.bootstrapCodex() } }
|
|
case .failed:
|
|
PlanFailedView(error: store.codexError)
|
|
case .transientFailure:
|
|
if let usage = store.codexUsage {
|
|
loadedBody(usage: usage)
|
|
} else {
|
|
PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.")
|
|
}
|
|
case let .terminalFailure(reason):
|
|
PlanReconnectView(
|
|
title: "Reconnect Codex",
|
|
reason: reason,
|
|
fallback: "Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect."
|
|
) { Task { await store.bootstrapCodex() } }
|
|
case .loaded:
|
|
if let usage = store.codexUsage {
|
|
loadedBody(usage: usage)
|
|
} else {
|
|
PlanLoadingView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func loadedBody(usage: CodexUsage) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(usage.plan.displayName)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt {
|
|
Text("Resets \(relativeReset(resetsAt))")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let primary = usage.primary {
|
|
UtilizationRow(
|
|
label: "\(primary.windowLabel) window",
|
|
percent: primary.usedPercent,
|
|
resetsAt: primary.resetsAt,
|
|
projection: nil
|
|
)
|
|
}
|
|
if let secondary = usage.secondary {
|
|
UtilizationRow(
|
|
label: "\(secondary.windowLabel) window",
|
|
percent: secondary.usedPercent,
|
|
resetsAt: secondary.resetsAt,
|
|
projection: nil
|
|
)
|
|
}
|
|
// Surface non-zero per-model rate limits (Codex Spark, etc.) so
|
|
// power users see them; idle ones stay collapsed.
|
|
ForEach(Array(usage.additionalLimits.enumerated()), id: \.offset) { _, limit in
|
|
if let p = limit.primary, p.usedPercent > 0 {
|
|
UtilizationRow(
|
|
label: "\(limit.name) · \(p.windowLabel)",
|
|
percent: p.usedPercent,
|
|
resetsAt: p.resetsAt,
|
|
projection: nil
|
|
)
|
|
}
|
|
if let s = limit.secondary, s.usedPercent > 0 {
|
|
UtilizationRow(
|
|
label: "\(limit.name) · \(s.windowLabel)",
|
|
percent: s.usedPercent,
|
|
resetsAt: s.resetsAt,
|
|
projection: nil
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.top, 4)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
private func relativeReset(_ date: Date) -> String {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .short
|
|
return f.localizedString(for: date, relativeTo: Date())
|
|
}
|
|
}
|
|
|
|
private struct WindowProjection {
|
|
enum Source { case linear, historicalBaseline }
|
|
let percent: Double
|
|
let willOverflow: Bool
|
|
let hitsLimitAt: Date?
|
|
let source: Source
|
|
}
|
|
|
|
private struct UtilizationRow: View {
|
|
let label: String
|
|
/// API returns utilization as 0..100 (a percentage value, not a fraction).
|
|
let percent: Double
|
|
let resetsAt: Date?
|
|
let projection: WindowProjection?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 3) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(label)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(String(format: "%.0f%%", clampedPercent))
|
|
.font(.codeMono(size: 11, weight: .semibold))
|
|
.foregroundStyle(barColor)
|
|
.monospacedDigit()
|
|
}
|
|
UtilizationBar(
|
|
fraction: clampedPercent / 100,
|
|
color: barColor,
|
|
markerFraction: projection.map { min(max($0.percent, 0), 100) / 100 }
|
|
)
|
|
.frame(height: 6)
|
|
if let projection {
|
|
ProjectionCaption(projection: projection)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var clampedPercent: Double { min(max(percent, 0), 100) }
|
|
|
|
/// Single-color brand palette decision (see session notes): the number is the signal, not
|
|
/// the color. Keeping this as a computed property so a future threshold-based palette
|
|
/// reintroduction stays scoped to one place.
|
|
private var barColor: Color { Theme.brandAccent }
|
|
}
|
|
|
|
private struct ProjectionCaption: View {
|
|
let projection: WindowProjection
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
if projection.willOverflow {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(Theme.brandAccent)
|
|
} else {
|
|
Image(systemName: "arrow.up.right")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
Text(captionText)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(projection.willOverflow
|
|
? AnyShapeStyle(Theme.brandAccent)
|
|
: AnyShapeStyle(.tertiary))
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var captionText: String {
|
|
let projected = String(format: "%.0f%%", projection.percent)
|
|
switch projection.source {
|
|
case .linear:
|
|
if projection.willOverflow, let hit = projection.hitsLimitAt {
|
|
return "On pace: \(projected) at reset · hits 100% \(relativeReset(hit))"
|
|
}
|
|
return "On pace: \(projected) at reset"
|
|
case .historicalBaseline:
|
|
return "Based on last cycle: \(projected)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct UtilizationBar: View {
|
|
/// 0..1 fraction of the bar to fill.
|
|
let fraction: Double
|
|
let color: Color
|
|
/// Optional 0..1 marker position for projected utilization at reset.
|
|
let markerFraction: Double?
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 3).fill(Color.secondary.opacity(0.12))
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(color)
|
|
.frame(width: max(0, geo.size.width * CGFloat(fraction)))
|
|
if let m = markerFraction {
|
|
Rectangle()
|
|
.fill(Color.primary.opacity(0.55))
|
|
.frame(width: 1.5)
|
|
.offset(x: max(0, geo.size.width * CGFloat(m)) - 0.75)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func relativeReset(_ date: Date) -> String {
|
|
let interval = date.timeIntervalSinceNow
|
|
if interval <= 0 { return "now" }
|
|
let hours = interval / 3600
|
|
if hours < 1 {
|
|
let minutes = Int(ceil(interval / 60))
|
|
return "in \(minutes)m"
|
|
}
|
|
if hours < 24 { return "in \(Int(ceil(hours)))h" }
|
|
let days = Int(ceil(hours / 24))
|
|
return "in \(days)d"
|
|
}
|