import AppKit import CodexBarCore import SwiftUI import WidgetKit // MARK: - Entry View struct BurnDownWidgetView: View { let entry: BurnDownEntry var body: some View { let state = BurnDownState( snapshot: self.entry.snapshot, provider: self.entry.provider, selection: self.entry.window) Group { if let state, let window = state.selectedWindow { BurnDownLayout( window: window, provider: self.entry.provider, blankChart: state.blankPrimaryChart, resetsAtOverride: state.selectedResetOverride) } else { self.emptyState } } .containerBackground(for: .widget) { BurnWidgetBackground() } } private var emptyState: some View { VStack(spacing: 6) { Text("Open CodexBar") .font(.body) .fontWeight(.semibold) Text("Usage data will appear once the app refreshes.") .font(.caption) .multilineTextAlignment(.center) .opacity(0.55) } .padding(12) } } // MARK: - Main Layout private struct BurnDownLayout: View { @Environment(\.widgetRenderingMode) private var renderingMode @Environment(\.colorScheme) private var colorScheme let window: RateWindow let provider: UsageProvider /// True when the session window is blocked because the weekly budget is exhausted: /// suppress the chart and retarget "Resets in" to the weekly reset. var blankChart = false /// When set, "Resets in" counts down to this date (the weekly reset) instead of the /// session window's own reset. var resetsAtOverride: Date? var body: some View { let dark = self.colorScheme == .dark let isMonochrome = self.renderingMode != .fullColor let geom = BurnGeom(window: self.window) let theme = BurnTheme(provider: self.provider, geom: geom, dark: dark, isMonochrome: isMonochrome) let windowMins = self.window.windowMinutes ?? 300 let isDailyWindow = windowMins >= 1440 let now = Date() let estimatedResetMinutes = self.blankChart || geom.tNow >= 1 ? nil : (1 - geom.tNow) * Double(windowMins) let explicitReset = self.blankChart ? self.resetsAtOverride : self.resetsAtOverride ?? self.window.resetsAt let effectiveResetAt = burnEffectiveResetDate( explicitResetAt: explicitReset, estimatedResetMinutes: estimatedResetMinutes, now: now) let resetsIn = effectiveResetAt.map { max(0, $0.timeIntervalSince(now) / 60) } ?? 0 let outInMins = geom.slope < -0.01 ? (geom.vNow / -geom.slope) * Double(windowMins) : Double.infinity // Very early in the window a single sample can't forecast a credible run-out: a // tiny burst right after reset extrapolates to "runs dry in minutes" even at ~99% // remaining. Match the design's fresh-window behaviour ("Runs out: after reset") // and only surface the estimate once enough of the window has elapsed to trust the // average burn rate. let windowEstablished = geom.tNow >= 0.08 let runsDryBefore = geom.runsOut && outInMins < resetsIn && windowEstablished let sign = geom.margin >= 0 ? "+" : "−" let badgeNum = "\(sign)\(abs(Int(geom.margin.rounded())))%" // Per the design's edge states, "fresh" and "spent" show only the glyph + word // (◆ full / ■ spent) with no pace number — the margin is meaningless once the // budget is full or gone. let showBadgeNumber = !geom.depleted && !geom.fresh let statusWord: String = geom.depleted ? "spent" : geom.fresh ? "full" : geom.status == .ahead ? "conserving" : geom.status == .behind ? "over pace" : "on pace" let arrow: String = geom.depleted ? "■" : geom.fresh ? "◆" : geom.status == .ahead ? "▲" : geom.status == .behind ? "▼" : "●" let axisDates = burnAxisDateRange( effectiveResetAt: effectiveResetAt, windowMinutes: windowMins, now: now) let startLabel = burnAxisLabel(axisDates.start, isDailyWindow: isDailyWindow) let resetLabel = burnAxisLabel(axisDates.reset, isDailyWindow: isDailyWindow) VStack(spacing: 0) { // Header: brand + pace badge HStack(alignment: .top) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Circle() .fill(theme.brandDot) .frame(width: 7, height: 7) .shadow(color: theme.brandDot.opacity(0.7), radius: 3.5) Text(burnProviderName(self.provider)) .font(.system(size: 14.5, weight: .semibold)) .foregroundStyle(theme.text) .lineLimit(1) } Text(burnWindowLabel(self.window.windowMinutes)) .font(.system(size: 11)) .foregroundStyle(theme.sub) .kerning(0.2) } Spacer() VStack(alignment: .trailing, spacing: 1) { if showBadgeNumber { Text(badgeNum) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(theme.statusColor) .monospacedDigit() } HStack(spacing: 3) { Text(arrow) .font(.system(size: 8)) .foregroundStyle(theme.statusColor) Text(statusWord) .font(.system(size: 10.5)) .foregroundStyle(theme.sub) } } } // Body: stats + hero / chart HStack(alignment: .bottom, spacing: 13) { // Left: stats + hero % VStack(alignment: .leading, spacing: 0) { VStack(spacing: 5) { BurnResetStatRow(resetAt: effectiveResetAt, theme: theme) BurnStatRow( label: geom.depleted ? "Ran out" : runsDryBefore ? "Runs out in" : "Runs out", value: geom .depleted ? "budget spent" : runsDryBefore ? "~\(burnFmtDuration(outInMins))" : "after reset", theme: theme, danger: geom.depleted || runsDryBefore) } .padding(.top, 8) Spacer() HStack(alignment: .lastTextBaseline, spacing: 5) { Text("\(Int(geom.vNow.rounded()))") .font(.system(size: 41, weight: .semibold)) .foregroundStyle(geom.depleted ? theme.danger : theme.text) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.75) Text("%") .font(.system(size: 19, weight: .medium)) .foregroundStyle(theme.sub) Text("left") .font(.system(size: 11)) .foregroundStyle(theme.sub) } } .frame(width: 143, alignment: .leading) // Right: chart + axis. Blanked when the session window is blocked by the // weekly cap — there's no session burn to chart until the weekly resets. VStack(spacing: 2) { if self.blankChart { Color.clear.frame(height: 84) Color.clear.frame(height: 13) } else { BurnChartCanvas( geom: geom, theme: theme) .frame(height: 84) BurnAxisRow( startLabel: startLabel, resetLabel: resetLabel, tNow: geom.tNow, theme: theme) .frame(height: 13) } } .frame(maxWidth: .infinity) } } .padding(.horizontal, 15) .padding(.top, 13) .padding(.bottom, 12) } } // MARK: - Stat Row private struct BurnStatRow: View { let label: String let value: String let theme: BurnTheme let danger: Bool var body: some View { HStack(alignment: .firstTextBaseline) { Text(self.label) .font(.system(size: 11.5)) .foregroundStyle(self.theme.sub) Spacer() Text(self.value) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(self.danger ? self.theme.danger : self.theme.text) .monospacedDigit() .lineLimit(1) } } } private struct BurnResetStatRow: View { let resetAt: Date? let theme: BurnTheme var body: some View { HStack(alignment: .firstTextBaseline) { Text("Resets in") .font(.system(size: 11.5)) .foregroundStyle(self.theme.sub) Spacer() if let resetAt { Text(resetAt, style: .relative) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(self.theme.text) .monospacedDigit() .lineLimit(1) } else { Text("—") .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(self.theme.text) } } } } // MARK: - Axis Row private struct BurnAxisRow: View { let startLabel: String let resetLabel: String let tNow: Double let theme: BurnTheme var body: some View { GeometryReader { geo in // Hide "now" when it would collide with the start/reset labels. The edge labels // are anchored to the ends, so estimate their widths (≈9.5pt monospaced digits) // and only show "now" when it clears both with a small gap — otherwise the // now-dot on the chart already conveys position. Matches the design's rule that // "now" hides near an end label. let w = geo.size.width let approxChar: CGFloat = 5.8 let nowX = self.tNow * w let nowHalf: CGFloat = 13 let gap: CGFloat = 6 let clearsStart = nowX - nowHalf > CGFloat(self.startLabel.count) * approxChar + gap let clearsReset = nowX + nowHalf < w - CGFloat(self.resetLabel.count) * approxChar - gap let showNow = self.tNow > 0.05 && self.tNow < 0.95 && clearsStart && clearsReset ZStack(alignment: .leading) { Text(self.startLabel) .font(.system(size: 9.5)) .foregroundStyle(self.theme.sub) .monospacedDigit() .frame(maxWidth: .infinity, alignment: .leading) if showNow { Text("now") .font(.system(size: 9.5, weight: .semibold)) .foregroundStyle(self.theme.text) .position(x: nowX, y: geo.size.height / 2) } Text(self.resetLabel) .font(.system(size: 9.5)) .foregroundStyle(self.theme.sub) .monospacedDigit() .frame(maxWidth: .infinity, alignment: .trailing) } } } } // MARK: - Chart Canvas private struct BurnChartCanvas: View { let geom: BurnGeom let theme: BurnTheme var body: some View { Canvas { context, size in let w = size.width let h = size.height let padT: CGFloat = 8 let padB: CGFloat = 2 let padL: CGFloat = 1 let padR: CGFloat = 1 func X(_ t: Double) -> CGFloat { padL + CGFloat(t) * (w - padL - padR) } func Y(_ v: Double) -> CGFloat { padT + CGFloat(1 - v / 100) * (h - padT - padB) } let tNow = self.geom.tNow let vNow = self.geom.vNow // --- Now vertical hairline --- do { var p = Path() p.move(to: CGPoint(x: X(tNow), y: Y(100))) p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) } // --- Baseline --- do { var p = Path() p.move(to: CGPoint(x: X(0), y: Y(0))) p.addLine(to: CGPoint(x: X(1), y: Y(0))) context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) } // --- Area fill (gradient from actual line down to baseline) --- do { var p = Path() p.move(to: CGPoint(x: X(0), y: Y(100))) p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) p.addLine(to: CGPoint(x: X(0), y: Y(0))) p.closeSubpath() let gradient = Gradient(stops: [ .init(color: self.theme.chartFillTop.opacity(self.theme.chartFillTopOpacity), location: 0), .init(color: self.theme.chartFillTop.opacity(0), location: 0.92), ]) context.fill( p, with: .linearGradient( gradient, startPoint: CGPoint(x: 0, y: padT), endPoint: CGPoint(x: 0, y: h))) } // --- Ideal line (dashed, knocked back) --- do { var p = Path() p.move(to: CGPoint(x: X(0), y: Y(100))) p.addLine(to: CGPoint(x: X(1), y: Y(0))) context.stroke( p, with: .color(self.theme.chartIdeal), style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3])) } // --- Projection (fine dotted) --- if self.geom.slope < -0.01 { var p = Path() p.move(to: CGPoint(x: X(tNow), y: Y(vNow))) p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV))) context.stroke( p, with: .color(self.theme.chartProj.opacity(0.95)), style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5])) } // --- Actual line (solid, hero) --- do { var p = Path() p.move(to: CGPoint(x: X(0), y: Y(100))) p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) context.stroke( p, with: .color(self.theme.chartLine), style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) } // --- Now dot (filled, with ring punched in bg color) --- let dotCenter = CGPoint(x: X(tNow), y: Y(vNow)) let ringPath = Path(ellipseIn: CGRect( x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8)) context.fill(ringPath, with: .color(self.theme.chartNowRing)) let dotPath = Path(ellipseIn: CGRect( x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8)) context.fill(dotPath, with: .color(self.theme.chartNowDot)) } } } // MARK: - Background struct BurnWidgetBackground: View { @Environment(\.widgetRenderingMode) private var renderingMode @Environment(\.colorScheme) private var colorScheme var body: some View { if self.renderingMode == .fullColor { let dark = self.colorScheme == .dark LinearGradient( colors: dark ? [BurnPalette.darkBgTop, BurnPalette.darkBgBottom] : [BurnPalette.lightBgTop, BurnPalette.lightBgBottom], startPoint: .init(x: 0.15, y: 0), endPoint: .init(x: 0.85, y: 1)) .overlay(alignment: .top) { LinearGradient( colors: [ Color.white.opacity(dark ? 0.04 : 0.60), Color.white.opacity(0), ], startPoint: .top, endPoint: .center) } } else { Color.clear } } } // MARK: - Theme struct BurnTheme { let text: Color let sub: Color let hair: Color let accent: Color let statusColor: Color let danger: Color let brandDot: Color let chartLine: Color let chartFillTop: Color let chartFillTopOpacity: Double let chartIdeal: Color let chartProj: Color let chartGrid: Color let chartNowDot: Color let chartNowRing: Color init(provider: UsageProvider, geom: BurnGeom, dark: Bool, isMonochrome: Bool) { if isMonochrome { let fg = dark ? Color.white : Color.black self.text = fg.opacity(dark ? 0.95 : 0.90) self.sub = fg.opacity(dark ? 0.50 : 0.46) self.hair = fg.opacity(dark ? 0.13 : 0.11) self.accent = fg.opacity(dark ? 0.95 : 0.85) self.statusColor = fg.opacity(dark ? 0.90 : 0.80) self.danger = fg.opacity(dark ? 0.95 : 0.85) self.brandDot = fg.opacity(dark ? 0.85 : 0.72) self.chartLine = fg.opacity(dark ? 0.95 : 0.85) self.chartFillTop = fg.opacity(dark ? 0.95 : 0.85) self.chartFillTopOpacity = dark ? 0.20 : 0.16 self.chartIdeal = fg.opacity(dark ? 0.36 : 0.30) self.chartProj = fg.opacity(dark ? 0.60 : 0.48) self.chartGrid = fg.opacity(dark ? 0.12 : 0.10) self.chartNowDot = fg.opacity(dark ? 1.0 : 0.92) self.chartNowRing = dark ? Color(red: 0.10, green: 0.10, blue: 0.12).opacity(0.92) : Color(red: 0.96, green: 0.96, blue: 0.97).opacity(0.95) } else { let accentColor = Self.accentColor(geom.status, dark: dark) self.accent = accentColor self.statusColor = accentColor self.text = dark ? Color.white.opacity(0.98) : Color(white: 0.20) self.sub = dark ? Color(white: 0.60) : Color(white: 0.45) self.hair = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09) self.danger = BurnPalette.behindDark self.brandDot = Self.brandDotColor(provider) self.chartLine = accentColor self.chartFillTop = accentColor self.chartFillTopOpacity = dark ? 0.30 : 0.22 self.chartIdeal = dark ? Color.white.opacity(0.45) : Color.black.opacity(0.50) // Projection goes red when behind (the one allowed color cue in full-color mode) self.chartProj = geom.status == .behind ? BurnPalette.behindDark : accentColor self.chartGrid = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09) self.chartNowDot = accentColor self.chartNowRing = dark ? BurnPalette.darkBgBottom : BurnPalette.lightBgBottom } } private static func accentColor(_ status: BurnGeom.Status, dark: Bool) -> Color { switch status { case .ahead: dark ? BurnPalette.aheadDark : BurnPalette.aheadLight case .onpace: dark ? BurnPalette.onpaceDark : BurnPalette.onpaceLight case .behind: dark ? BurnPalette.behindDark : BurnPalette.behindLight } } private static func brandDotColor(_ provider: UsageProvider) -> Color { switch provider { case .claude: BurnPalette.claudeDot case .codex: BurnPalette.codexDot case .gemini: BurnPalette.geminiDot default: BurnPalette.genericDot } } } // MARK: - Palette enum BurnPalette { // Status accents — approximated from OKLCH (L=0.80 dark, L=0.62 light) // oklch(0.80 0.15 152) / oklch(0.62 0.15 152) — green static let aheadDark = Color(red: 0.306, green: 0.800, blue: 0.506) static let aheadLight = Color(red: 0.192, green: 0.620, blue: 0.376) // oklch(0.80 0.11 236) / oklch(0.62 0.11 236) — blue static let onpaceDark = Color(red: 0.408, green: 0.668, blue: 0.910) static let onpaceLight = Color(red: 0.264, green: 0.474, blue: 0.712) // oklch(0.72 0.19 26) / oklch(0.60 0.19 26) — red-orange static let behindDark = Color(red: 0.922, green: 0.420, blue: 0.227) static let behindLight = Color(red: 0.762, green: 0.294, blue: 0.137) // Brand identity dots — always the LLM's hue static let claudeDot = Color(red: 0.880, green: 0.580, blue: 0.180) // clay/amber, hue 48 static let codexDot = Color(red: 0.120, green: 0.780, blue: 0.598) // teal, hue 168 static let geminiDot = Color(red: 0.420, green: 0.440, blue: 0.900) // indigo, hue 268 static let genericDot = Color(white: 0.60) // Backgrounds static let darkBgTop = Color(red: 0.108, green: 0.108, blue: 0.132) static let darkBgBottom = Color(red: 0.132, green: 0.132, blue: 0.156) static let lightBgTop = Color(white: 0.990) static let lightBgBottom = Color(red: 0.940, green: 0.940, blue: 0.960) } // MARK: - Geometry struct BurnGeom { enum Status { case ahead, onpace, behind } let vNow: Double // % remaining (0..100) let tNow: Double // position in window (0..1) let idealNow: Double // what you should have left = 100 * (1 - tNow) let margin: Double // vNow - idealNow; + = conserving, − = over pace let slope: Double // %/unit-t (negative = burning) let projT: Double // t where projection ends let projV: Double // v where projection ends let runsOut: Bool // projection hits 0 inside the window var status: Status { self.margin > 4 ? .ahead : self.margin < -4 ? .behind : .onpace } var depleted: Bool { self.vNow <= 0.5 } var fresh: Bool { self.vNow >= 99.5 } init(window: RateWindow) { let remaining = max(0, min(100, window.remainingPercent)) self.vNow = remaining let t: Double if let resetsAt = window.resetsAt, let windowMins = window.windowMinutes, windowMins > 0 { let minutesUntilReset = max(0, resetsAt.timeIntervalSinceNow / 60) let minutesElapsed = Double(windowMins) - minutesUntilReset t = max(0.001, min(0.999, minutesElapsed / Double(windowMins))) } else { t = max(0.001, min(0.999, window.usedPercent / 100.0)) } self.tNow = t self.idealNow = 100.0 * (1.0 - t) self.margin = remaining - self.idealNow let slope = t > 0.001 ? (remaining - 100.0) / t : -remaining self.slope = slope if slope < -0.01 { let tOut = t + remaining / -slope if tOut <= 1.0 { self.projT = tOut self.projV = 0 self.runsOut = true } else { self.projT = 1.0 self.projV = max(0, remaining + slope * (1.0 - t)) self.runsOut = false } } else { self.projT = 1.0 self.projV = remaining self.runsOut = false } } } // MARK: - Helpers func burnWindowLabel(_ windowMinutes: Int?) -> String { guard let mins = windowMinutes else { return "Usage limit" } if mins < 60 { return "\(mins)-minute limit" } let hours = mins / 60 if hours < 24 { return "\(hours)-hour limit" } return "\(hours / 24)-day limit" } func burnEffectiveResetDate( explicitResetAt: Date?, estimatedResetMinutes: Double?, now: Date) -> Date? { if let explicitResetAt { return explicitResetAt > now ? explicitResetAt : nil } guard let estimatedResetMinutes, estimatedResetMinutes > 0 else { return nil } return now.addingTimeInterval(estimatedResetMinutes * 60) } func burnAxisDateRange( effectiveResetAt: Date?, windowMinutes: Int, now: Date) -> (start: Date, reset: Date) { let reset = effectiveResetAt ?? now return (reset.addingTimeInterval(-Double(windowMinutes) * 60), reset) } func burnCompactWindowLabel(_ windowMinutes: Int?, fallback: String) -> String { guard let minutes = windowMinutes else { return fallback } if minutes < 60 { return "\(minutes)M" } let hours = minutes / 60 if hours < 24 { return "\(hours)H" } return "\(hours / 24)D" } func burnFmtDuration(_ minutes: Double) -> String { guard minutes.isFinite, minutes > 0 else { return "—" } if minutes >= 1440 { let d = Int(minutes / 1440) let h = Int(minutes / 60) % 24 return "\(d)d \(h)h" } let h = Int(minutes / 60) let m = Int(minutes) % 60 if h <= 0 { return "\(max(1, m))m" } return "\(h)h \(String(format: "%02d", m))m" } func burnAxisLabel(_ date: Date, isDailyWindow: Bool) -> String { let f = DateFormatter() if isDailyWindow { // A rolling multi-day window's start and reset fall on the same weekday, so "EEE" // would print the same label at both ends ("Sat … Sat"). Use a numeric date so the // two ends are distinguishable. f.setLocalizedDateFormatFromTemplate("Md") } else { f.dateStyle = .none f.timeStyle = .short } return f.string(from: date) } func burnProviderName(_ provider: UsageProvider) -> String { ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized }