chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:33 +08:00
commit fc4fcbab58
1848 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,228 @@
import AppIntents
import CodexBarCore
import WidgetKit
enum BurnProviderChoice: String, AppEnum {
case codex
case claude
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
static let caseDisplayRepresentations: [BurnProviderChoice: DisplayRepresentation] = [
.codex: DisplayRepresentation(title: "Codex"),
.claude: DisplayRepresentation(title: "Claude"),
]
var provider: UsageProvider {
switch self {
case .codex: .codex
case .claude: .claude
}
}
}
enum BurnWindowChoice: String, AppEnum {
case session
case weekly
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Usage window")
static let caseDisplayRepresentations: [BurnWindowChoice: DisplayRepresentation] = [
.session: DisplayRepresentation(title: "Session (5-hour)"),
.weekly: DisplayRepresentation(title: "Weekly (7-day)"),
]
}
struct BurnDownSelectionIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Burn Down"
static let description = IntentDescription("Select the provider and usage window to display.")
@Parameter(title: "Provider", default: .codex)
var provider: BurnProviderChoice
@Parameter(title: "Usage window", default: .session)
var window: BurnWindowChoice
init() {
self.provider = .codex
self.window = .session
}
}
struct BurnProviderSelectionIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Burn Down Provider"
static let description = IntentDescription("Select the provider to display.")
@Parameter(title: "Provider", default: .codex)
var provider: BurnProviderChoice
init() {
self.provider = .codex
}
}
struct BurnDownEntry: TimelineEntry {
let date: Date
let provider: UsageProvider
let window: BurnWindowChoice
let snapshot: WidgetSnapshot
}
struct CombinedBurnDownEntry: TimelineEntry {
let date: Date
let provider: UsageProvider
let snapshot: WidgetSnapshot
}
struct BurnDownState {
private static let sessionWindowMinutes = 5 * 60
private static let weeklyWindowMinutes = 7 * 24 * 60
let entry: WidgetSnapshot.ProviderEntry
let selection: BurnWindowChoice
let now: Date
init?(
snapshot: WidgetSnapshot,
provider: UsageProvider,
selection: BurnWindowChoice,
now: Date = Date())
{
guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return nil }
self.entry = entry
self.selection = selection
self.now = now
}
var secondaryGloballyCapsPrimary: Bool {
switch self.entry.provider {
case .codex, .claude: true
default: false
}
}
var secondaryExhausted: Bool {
guard self.secondaryGloballyCapsPrimary, let secondary = self.secondaryWindow else { return false }
guard secondary.remainingPercent <= 0 else { return false }
return secondary.resetsAt.map { $0 > self.now } ?? true
}
var primaryWindow: RateWindow? {
guard let primary = self.window(minutes: Self.sessionWindowMinutes) else { return nil }
guard self.secondaryExhausted, primary.remainingPercent > 0 else { return primary }
return RateWindow(
usedPercent: 100,
windowMinutes: primary.windowMinutes,
resetsAt: primary.resetsAt,
resetDescription: primary.resetDescription,
nextRegenPercent: primary.nextRegenPercent)
}
var secondaryWindow: RateWindow? {
self.window(minutes: Self.weeklyWindowMinutes)
}
var selectedWindow: RateWindow? {
switch self.selection {
case .session: self.primaryWindow
case .weekly: self.secondaryWindow
}
}
var blankPrimaryChart: Bool {
self.selection == .session
&& self.secondaryExhausted
&& self.window(minutes: Self.sessionWindowMinutes) != nil
}
var selectedResetOverride: Date? {
self.blankPrimaryChart ? self.secondaryWindow?.resetsAt : nil
}
private func window(minutes: Int) -> RateWindow? {
[self.entry.primary, self.entry.secondary]
.compactMap(\.self)
.first { $0.windowMinutes == minutes }
}
}
enum BurnDownRefreshSchedule {
private static let maximumInterval: TimeInterval = 30 * 60
static func nextRefresh(
snapshot: WidgetSnapshot,
provider: UsageProvider,
now: Date = Date()) -> Date
{
let fallback = now.addingTimeInterval(self.maximumInterval)
guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return fallback }
let nextReset = [entry.primary?.resetsAt, entry.secondary?.resetsAt]
.compactMap(\.self)
.filter { $0 > now }
.min()?
.addingTimeInterval(1)
return min(fallback, nextReset ?? fallback)
}
}
struct BurnDownTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> BurnDownEntry {
BurnDownEntry(
date: Date(),
provider: .codex,
window: .session,
snapshot: WidgetPreviewData.snapshot())
}
func snapshot(for configuration: BurnDownSelectionIntent, in context: Context) async -> BurnDownEntry {
BurnDownEntry(
date: Date(),
provider: configuration.provider.provider,
window: configuration.window,
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
}
func timeline(
for configuration: BurnDownSelectionIntent,
in context: Context) async -> Timeline<BurnDownEntry>
{
let entry = BurnDownEntry(
date: Date(),
provider: configuration.provider.provider,
window: configuration.window,
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot())
let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider)
return Timeline(entries: [entry], policy: .after(refresh))
}
}
struct CombinedBurnDownTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> CombinedBurnDownEntry {
CombinedBurnDownEntry(
date: Date(),
provider: .codex,
snapshot: WidgetPreviewData.snapshot())
}
func snapshot(
for configuration: BurnProviderSelectionIntent,
in context: Context) async -> CombinedBurnDownEntry
{
CombinedBurnDownEntry(
date: Date(),
provider: configuration.provider.provider,
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
}
func timeline(
for configuration: BurnProviderSelectionIntent,
in context: Context) async -> Timeline<CombinedBurnDownEntry>
{
let entry = CombinedBurnDownEntry(
date: Date(),
provider: configuration.provider.provider,
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot())
let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider)
return Timeline(entries: [entry], policy: .after(refresh))
}
}
@@ -0,0 +1,682 @@
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
}
@@ -0,0 +1,115 @@
import SwiftUI
import WidgetKit
@main
struct CodexBarWidgetBundle: WidgetBundle {
var body: some Widget {
CodexBarSwitcherWidget()
CodexBarUsageWidget()
CodexBarHistoryWidget()
CodexBarCompactWidget()
CodexBarBurnDownWidget()
CodexBarCombinedBurnDownWidget()
}
}
struct CodexBarSwitcherWidget: Widget {
private let kind = "CodexBarSwitcherWidget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: self.kind,
provider: CodexBarSwitcherTimelineProvider())
{ entry in
CodexBarSwitcherWidgetView(entry: entry)
}
.configurationDisplayName("CodexBar Switcher")
.description("Usage widget with a provider switcher.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
struct CodexBarUsageWidget: Widget {
private let kind = "CodexBarUsageWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: self.kind,
intent: ProviderSelectionIntent.self,
provider: CodexBarTimelineProvider())
{ entry in
CodexBarUsageWidgetView(entry: entry)
}
.configurationDisplayName("CodexBar Usage")
.description("Session and weekly usage with credits and costs.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
struct CodexBarHistoryWidget: Widget {
private let kind = "CodexBarHistoryWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: self.kind,
intent: ProviderSelectionIntent.self,
provider: CodexBarTimelineProvider())
{ entry in
CodexBarHistoryWidgetView(entry: entry)
}
.configurationDisplayName("CodexBar History")
.description("Usage history chart with recent totals.")
.supportedFamilies([.systemMedium, .systemLarge])
}
}
struct CodexBarCompactWidget: Widget {
private let kind = "CodexBarCompactWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: self.kind,
intent: CompactMetricSelectionIntent.self,
provider: CodexBarCompactTimelineProvider())
{ entry in
CodexBarCompactWidgetView(entry: entry)
}
.configurationDisplayName("CodexBar Metric")
.description("Compact widget for credits or cost.")
.supportedFamilies([.systemSmall])
}
}
struct CodexBarBurnDownWidget: Widget {
private let kind = "CodexBarBurnDownWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: self.kind,
intent: BurnDownSelectionIntent.self,
provider: BurnDownTimelineProvider())
{ entry in
BurnDownWidgetView(entry: entry)
}
.configurationDisplayName("CodexBar Burn Down")
.description("Remaining budget compared with an ideal steady burn rate.")
.supportedFamilies([.systemMedium])
}
}
struct CodexBarCombinedBurnDownWidget: Widget {
private let kind = "CodexBarCombinedBurnDownWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: self.kind,
intent: BurnProviderSelectionIntent.self,
provider: CombinedBurnDownTimelineProvider())
{ entry in
CombinedBurnDownWidgetView(entry: entry)
}
.configurationDisplayName("CodexBar Burn Down (Combined)")
.description("Session and weekly burn-down charts in one tile.")
.supportedFamilies([.systemMedium])
}
}
@@ -0,0 +1,366 @@
import AppIntents
import CodexBarCore
import SwiftUI
import WidgetKit
enum ProviderChoice: String, AppEnum {
case codex
case claude
case gemini
case alibaba
case alibabatokenplan
case antigravity
case cursor
case zai
case copilot
case devin
case minimax
case kilo
case opencode
case opencodego
case mistral
case kimi
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
static let caseDisplayRepresentations: [ProviderChoice: DisplayRepresentation] = [
.codex: DisplayRepresentation(title: "Codex"),
.claude: DisplayRepresentation(title: "Claude"),
.gemini: DisplayRepresentation(title: "Gemini"),
.alibaba: DisplayRepresentation(title: "Alibaba"),
.alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"),
.antigravity: DisplayRepresentation(title: "Antigravity"),
.cursor: DisplayRepresentation(title: "Cursor"),
.zai: DisplayRepresentation(title: "z.ai"),
.copilot: DisplayRepresentation(title: "Copilot"),
.devin: DisplayRepresentation(title: "Devin"),
.minimax: DisplayRepresentation(title: "MiniMax"),
.kilo: DisplayRepresentation(title: "Kilo"),
.opencode: DisplayRepresentation(title: "OpenCode"),
.opencodego: DisplayRepresentation(title: "OpenCode Go"),
.mistral: DisplayRepresentation(title: "Mistral"),
.kimi: DisplayRepresentation(title: "Kimi"),
]
var provider: UsageProvider {
switch self {
case .codex: .codex
case .claude: .claude
case .gemini: .gemini
case .alibaba: .alibaba
case .alibabatokenplan: .alibabatokenplan
case .antigravity: .antigravity
case .cursor: .cursor
case .zai: .zai
case .copilot: .copilot
case .devin: .devin
case .minimax: .minimax
case .kilo: .kilo
case .opencode: .opencode
case .opencodego: .opencodego
case .mistral: .mistral
case .kimi: .kimi
}
}
// swiftlint:disable:next cyclomatic_complexity
init?(provider: UsageProvider) {
switch provider {
case .codex: self = .codex
case .openai: return nil // OpenAI not yet supported in widgets
case .azureopenai: return nil // Azure OpenAI not yet supported in widgets
case .claude: self = .claude
case .gemini: self = .gemini
case .alibaba: self = .alibaba
case .alibabatokenplan: self = .alibabatokenplan
case .antigravity: self = .antigravity
case .cursor: self = .cursor
case .opencode: self = .opencode
case .opencodego: self = .opencodego
case .zai: self = .zai
case .factory: return nil // Factory not yet supported in widgets
case .copilot: self = .copilot
case .devin: self = .devin
case .minimax: self = .minimax
case .manus: return nil // Manus not yet supported in widgets
case .vertexai: return nil // Vertex AI not yet supported in widgets
case .kilo: self = .kilo
case .kiro: return nil // Kiro not yet supported in widgets
case .augment: return nil // Augment not yet supported in widgets
case .jetbrains: return nil // JetBrains not yet supported in widgets
case .kimi: self = .kimi
case .kimik2: return nil // Kimi K2 not yet supported in widgets
case .moonshot: return nil // Moonshot not yet supported in widgets
case .amp: return nil // Amp not yet supported in widgets
case .t3chat: return nil // T3 Chat not yet supported in widgets
case .ollama: return nil // Ollama not yet supported in widgets
case .synthetic: return nil // Synthetic not yet supported in widgets
case .openrouter: return nil // OpenRouter not yet supported in widgets
case .crossmodel: return nil // CrossModel not yet supported in widgets
case .clawrouter: return nil // ClawRouter not yet supported in widgets
case .sub2api: return nil // sub2api not yet supported in widgets
case .wayfinder: return nil // Wayfinder not yet supported in widgets
case .elevenlabs: return nil // ElevenLabs not yet supported in widgets
case .warp: return nil // Warp not yet supported in widgets
case .windsurf: return nil // Windsurf not yet supported in widgets
case .perplexity: return nil // Perplexity not yet supported in widgets
case .mimo: return nil // Xiaomi MiMo not yet supported in widgets
case .doubao: return nil // Doubao not yet supported in widgets
case .sakana: return nil // Sakana AI not yet supported in widgets
case .abacus: return nil // Abacus AI not yet supported in widgets
case .mistral: self = .mistral
case .deepseek: return nil // DeepSeek not yet supported in widgets
case .codebuff: return nil // Codebuff not yet supported in widgets
case .crof: return nil // Crof not yet supported in widgets
case .venice: return nil // Venice not yet supported in widgets
case .commandcode: return nil // CommandCode not yet supported in widgets
case .qoder: return nil // Qoder not yet supported in widgets
case .stepfun: return nil // StepFun not yet supported in widgets
case .bedrock: return nil // Bedrock not yet supported in widgets
case .grok: return nil // Grok not yet supported in widgets
case .groq: return nil // Groq not yet supported in widgets
case .llmproxy: return nil // LLM Proxy not yet supported in widgets
case .litellm: return nil // LiteLLM not yet supported in widgets
case .deepgram: return nil // Deepgram not yet supported in widgets
case .poe: return nil // Poe not yet supported in widgets
case .chutes: return nil // Chutes not yet supported in widgets
case .zed: return nil // Zed not yet supported in widgets
}
}
}
enum CompactMetric: String, AppEnum {
case credits
case todayCost
case last30DaysCost
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Metric")
static let caseDisplayRepresentations: [CompactMetric: DisplayRepresentation] = [
.credits: DisplayRepresentation(title: "Credits left"),
.todayCost: DisplayRepresentation(title: "Today cost"),
.last30DaysCost: DisplayRepresentation(title: "30d cost"),
]
}
struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Provider"
static let description = IntentDescription("Select the provider to display in the widget.")
@Parameter(title: "Provider", default: .codex)
var provider: ProviderChoice
init() {
self.provider = .codex
}
}
struct SwitchWidgetProviderIntent: AppIntent {
static let title: LocalizedStringResource = "Switch Provider"
static let description = IntentDescription("Switch the provider shown in the widget.")
@Parameter(title: "Provider")
var provider: ProviderChoice
init() {}
init(provider: ProviderChoice) {
self.provider = provider
}
func perform() async throws -> some IntentResult {
WidgetSelectionStore.saveSelectedProvider(self.provider.provider)
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Provider + Metric"
static let description = IntentDescription("Select the provider and metric to display.")
@Parameter(title: "Provider", default: .codex)
var provider: ProviderChoice
@Parameter(title: "Metric", default: .credits)
var metric: CompactMetric
init() {
self.provider = .codex
self.metric = .credits
}
}
struct CodexBarWidgetEntry: TimelineEntry {
let date: Date
let provider: UsageProvider
let snapshot: WidgetSnapshot
}
struct CodexBarCompactEntry: TimelineEntry {
let date: Date
let provider: UsageProvider
let metric: CompactMetric
let snapshot: WidgetSnapshot
}
struct CodexBarSwitcherEntry: TimelineEntry {
let date: Date
let provider: UsageProvider
let availableProviders: [UsageProvider]
let snapshot: WidgetSnapshot
}
struct CodexBarTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> CodexBarWidgetEntry {
CodexBarWidgetEntry(
date: Date(),
provider: .codex,
snapshot: WidgetPreviewData.snapshot())
}
func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async -> CodexBarWidgetEntry {
let provider = configuration.provider.provider
return CodexBarWidgetEntry(
date: Date(),
provider: provider,
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
}
func timeline(
for configuration: ProviderSelectionIntent,
in context: Context) async -> Timeline<CodexBarWidgetEntry>
{
let provider = configuration.provider.provider
let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
let now = Date()
let entry = CodexBarWidgetEntry(date: now, provider: provider, snapshot: snapshot)
let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: provider, now: now)
return Timeline(entries: [entry], policy: .after(refresh))
}
}
struct CodexBarSwitcherTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> CodexBarSwitcherEntry {
let snapshot = WidgetPreviewData.snapshot()
let providers = self.availableProviders(from: snapshot)
return CodexBarSwitcherEntry(
date: Date(),
provider: providers.first ?? .codex,
availableProviders: providers,
snapshot: snapshot)
}
func getSnapshot(in context: Context, completion: @escaping (CodexBarSwitcherEntry) -> Void) {
completion(self.makeEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CodexBarSwitcherEntry>) -> Void) {
let entry = self.makeEntry()
let refresh = BurnDownRefreshSchedule.nextRefresh(
snapshot: entry.snapshot,
provider: entry.provider,
now: entry.date)
completion(Timeline(entries: [entry], policy: .after(refresh)))
}
private func makeEntry() -> CodexBarSwitcherEntry {
let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
let providers = self.availableProviders(from: snapshot)
let stored = WidgetSelectionStore.loadSelectedProvider()
let selected = providers.first { $0 == stored } ?? providers.first ?? .codex
if selected != stored {
WidgetSelectionStore.saveSelectedProvider(selected)
}
return CodexBarSwitcherEntry(
date: Date(),
provider: selected,
availableProviders: providers,
snapshot: snapshot)
}
private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
Self.supportedProviders(from: snapshot)
}
static func supportedProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
let enabled = snapshot.enabledProviders
let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled
let supported = providers.filter { ProviderChoice(provider: $0) != nil }
return supported.isEmpty ? [.codex] : supported
}
}
struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> CodexBarCompactEntry {
CodexBarCompactEntry(
date: Date(),
provider: .codex,
metric: .credits,
snapshot: WidgetPreviewData.snapshot())
}
func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry {
let provider = configuration.provider.provider
let metric = configuration.metric
return CodexBarCompactEntry(
date: Date(),
provider: provider,
metric: metric,
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
}
func timeline(
for configuration: CompactMetricSelectionIntent,
in context: Context) async -> Timeline<CodexBarCompactEntry>
{
let provider = configuration.provider.provider
let metric = configuration.metric
let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
let entry = CodexBarCompactEntry(
date: Date(),
provider: provider,
metric: metric,
snapshot: snapshot)
let refresh = Date().addingTimeInterval(30 * 60)
return Timeline(entries: [entry], policy: .after(refresh))
}
}
enum WidgetPreviewData {
static func emptySnapshot() -> WidgetSnapshot {
WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date())
}
static func snapshot() -> WidgetSnapshot {
let primary = RateWindow(usedPercent: 35, windowMinutes: 300, resetsAt: nil, resetDescription: "Resets in 4h")
let secondary = RateWindow(
usedPercent: 60,
windowMinutes: 10080,
resetsAt: nil,
resetDescription: "Resets in 3d")
let entry = WidgetSnapshot.ProviderEntry(
provider: .codex,
updatedAt: Date(),
primary: primary,
secondary: secondary,
tertiary: nil,
creditsRemaining: 1243.4,
codeReviewRemainingPercent: 78,
tokenUsage: WidgetSnapshot.TokenUsageSummary(
sessionCostUSD: 12.4,
sessionTokens: 420_000,
last30DaysCostUSD: 923.8,
last30DaysTokens: 12_400_000),
dailyUsage: [
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-01", totalTokens: 120_000, costUSD: 15.2),
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-02", totalTokens: 80000, costUSD: 10.1),
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-03", totalTokens: 140_000, costUSD: 17.9),
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-04", totalTokens: 90000, costUSD: 11.4),
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-05", totalTokens: 160_000, costUSD: 19.8),
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-06", totalTokens: 70000, costUSD: 8.9),
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-07", totalTokens: 110_000, costUSD: 13.7),
])
return WidgetSnapshot(entries: [entry], generatedAt: Date())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,480 @@
import AppKit
import CodexBarCore
import SwiftUI
import WidgetKit
// MARK: - Entry View
struct CombinedBurnDownWidgetView: View {
let entry: CombinedBurnDownEntry
var body: some View {
let state = BurnDownState(
snapshot: self.entry.snapshot,
provider: self.entry.provider,
selection: .session)
Group {
if let state {
CombinedBurnDownLayout(state: state, provider: self.entry.provider)
} 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: - Layout
private struct CombinedBurnDownLayout: View {
@Environment(\.widgetRenderingMode) private var renderingMode
@Environment(\.colorScheme) private var colorScheme
let state: BurnDownState
let provider: UsageProvider
var body: some View {
let dark = self.colorScheme == .dark
let isMonochrome = self.renderingMode != .fullColor
let sessionWindow = self.state.primaryWindow
let weeklyWindow = self.state.secondaryWindow
let sessionGeom = sessionWindow.map { BurnGeom(window: $0) }
let weeklyGeom = weeklyWindow.map { BurnGeom(window: $0) }
// Use a neutral baseline theme for the header/hairline colors
let baseGeom = sessionGeom ?? weeklyGeom ?? BurnGeom(
window: RateWindow(
usedPercent: 50,
windowMinutes: 300,
resetsAt: Date().addingTimeInterval(2.5 * 3600),
resetDescription: nil))
let baseTheme = BurnTheme(
provider: self.provider,
geom: baseGeom,
dark: dark,
isMonochrome: isMonochrome)
VStack(spacing: 0) {
// Header
HStack(alignment: .center) {
HStack(spacing: 6) {
Circle()
.fill(baseTheme.brandDot)
.frame(width: 7, height: 7)
.shadow(color: baseTheme.brandDot.opacity(0.7), radius: 3.5)
Text(burnProviderName(self.provider))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(baseTheme.text)
.lineLimit(1)
}
Spacer()
Text("Session & weekly limits")
.font(.system(size: 10))
.foregroundStyle(baseTheme.sub)
.kerning(0.3)
}
// Two rows
VStack(spacing: 0) {
// 5H row shows % remaining by default
if let win = sessionWindow, let geom = sessionGeom {
CombinedBurnRow(
window: win,
geom: geom,
theme: BurnTheme(
provider: self.provider,
geom: geom,
dark: dark,
isMonochrome: isMonochrome),
tag: burnCompactWindowLabel(win.windowMinutes, fallback: "S"),
periods: 5,
metric: .remaining,
dark: dark,
blankChart: self.state.blankPrimaryChart,
resetsAtOverride: self.state.selectedResetOverride)
} else {
CombinedEmptyRow(tag: "S", theme: baseTheme)
}
Rectangle()
.fill(baseTheme.hair)
.frame(height: 1)
// 7D row shows % off pace by default
if let win = weeklyWindow, let geom = weeklyGeom {
CombinedBurnRow(
window: win,
geom: geom,
theme: BurnTheme(
provider: self.provider,
geom: geom,
dark: dark,
isMonochrome: isMonochrome),
tag: burnCompactWindowLabel(win.windowMinutes, fallback: "W"),
periods: 7,
metric: .pace,
dark: dark)
} else {
CombinedEmptyRow(tag: "W", theme: baseTheme)
}
}
.frame(maxHeight: .infinity)
.padding(.top, 6)
}
.padding(.horizontal, 15)
.padding(.top, 12)
.padding(.bottom, 11)
}
}
// MARK: - Metric
private enum CombinedMetric {
case remaining // % left (default for 5H)
case pace // % off ideal pace (default for 7D)
case used // % consumed
}
// MARK: - Row
private struct CombinedBurnRow: View {
let window: RateWindow
let geom: BurnGeom
let theme: BurnTheme
let tag: String
let periods: Int
let metric: CombinedMetric
let dark: Bool
var blankChart = false
var resetsAtOverride: Date?
var body: some View {
let windowMins = self.window.windowMinutes ?? 300
let isDailyWindow = windowMins >= 1440
let heroNum = self.metric == .pace ? abs(Int(self.geom.margin.rounded()))
: self.metric == .used ? Int((100 - self.geom.vNow).rounded())
: Int(self.geom.vNow.rounded())
let suffix = self.metric == .remaining ? "left" : self.metric == .used ? "used" : ""
let prefixArrow = self.metric == .pace
let paceWord: String = self.geom.depleted ? "spent" : self.geom.fresh ? "full"
: self.geom.status == .ahead ? "under pace"
: self.geom.status == .behind ? "over pace" : "on pace"
let arrow: String = self.geom.depleted ? "" : self.geom.fresh ? ""
: self.geom.status == .ahead ? "" : self.geom.status == .behind ? "" : ""
let explicitReset = self.blankChart
? self.resetsAtOverride
: self.resetsAtOverride ?? self.window.resetsAt
let now = Date()
let estimatedResetMinutes = self.blankChart || self.geom.tNow >= 1
? nil
: (1 - self.geom.tNow) * Double(windowMins)
let effectiveResetDate = burnEffectiveResetDate(
explicitResetAt: explicitReset,
estimatedResetMinutes: estimatedResetMinutes,
now: now)
let heroColor = self.geom.depleted ? self.theme.danger : self.theme.statusColor
HStack(alignment: .center, spacing: 12) {
// Label column
VStack(alignment: .leading, spacing: 0) {
// Line 1: tag + (arrow for non-pace metrics) + pace word
// For remaining/used: "5H over pace". For pace: "7D on pace" (arrow is on hero line).
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(self.tag)
.font(.system(size: 9.5, weight: .heavy))
.foregroundStyle(self.theme.sub)
.kerning(1)
HStack(alignment: .firstTextBaseline, spacing: 2) {
if !prefixArrow {
Text(arrow)
.font(.system(size: 8))
.foregroundStyle(self.theme.statusColor)
}
Text(paceWord)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(self.theme.statusColor)
}
.lineLimit(1)
}
// Line 2: hero number. Pace metric prefixes an arrow glyph.
HStack(alignment: .lastTextBaseline, spacing: 2) {
if prefixArrow {
Text(arrow)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(heroColor)
}
Text("\(heroNum)")
.font(.system(size: 27, weight: .semibold))
.foregroundStyle(heroColor)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.8)
Text("%")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(self.theme.sub)
if !suffix.isEmpty {
Text(suffix)
.font(.system(size: 10))
.foregroundStyle(self.theme.sub)
}
}
.padding(.top, 1)
// Line 3: reset line refresh glyph + countdown + compact time
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 9))
.foregroundStyle(self.theme.sub.opacity(0.85))
if let effectiveResetDate {
Text(effectiveResetDate, style: .relative)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(self.theme.text)
.monospacedDigit()
Text("· \(combinedCompactResetTime(effectiveResetDate, isDailyWindow: isDailyWindow))")
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(self.theme.text)
} else {
Text("")
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(self.theme.text)
}
}
.padding(.top, 2)
.lineLimit(1)
}
.frame(width: 112, alignment: .leading)
// Chart column blanked when the session window is blocked by an exhausted
// weekly cap; there is no session burn to chart until the weekly resets.
if self.blankChart {
Color.clear
.frame(maxWidth: .infinity)
.frame(height: 50)
} else {
CombinedBurnChartCanvas(geom: self.geom, theme: self.theme, periods: self.periods, dark: self.dark)
.frame(maxWidth: .infinity)
.frame(height: 50)
}
}
.frame(maxHeight: .infinity)
}
}
// MARK: - Empty Row
private struct CombinedEmptyRow: View {
let tag: String
let theme: BurnTheme
var body: some View {
HStack {
Text(self.tag)
.font(.system(size: 9.5, weight: .heavy))
.foregroundStyle(self.theme.sub)
.kerning(1)
Text("No data")
.font(.system(size: 10))
.foregroundStyle(self.theme.sub)
Spacer()
}
.frame(maxHeight: .infinity)
}
}
// MARK: - Mini Chart Canvas
private struct CombinedBurnChartCanvas: View {
let geom: BurnGeom
let theme: BurnTheme
let periods: Int
let dark: Bool
var body: some View {
Canvas { context, size in
let w = size.width
let h = size.height
let padT: CGFloat = 5
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
let barColor = self.dark ? Color.white : Color.black
// --- Usage bars (background texture) ---
// Drawn first so the actual line renders on top.
// Heights are relative-to-ideal: idealPerPeriod maps to ~46% of plot height.
let plotH = h - padT - padB
let refH = 0.46 * plotH // reference height = ideal-pace bar height
let idealPerPeriod = 100.0 / Double(self.periods)
let burnRate = tNow > 0.001 ? (100.0 - vNow) / tNow : 0.0 // %/unit-t
let slotW = (w - padL - padR) / CGFloat(self.periods)
for i in 0..<self.periods {
let slotStart = Double(i) / Double(self.periods)
let slotEnd = Double(i + 1) / Double(self.periods)
let slotX = padL + CGFloat(slotStart) * (w - padL - padR)
if slotEnd <= tNow {
// Completed period full-width bar
let consumed = burnRate * (slotEnd - slotStart)
let ratio = consumed / idealPerPeriod
let totalBarH = CGFloat(ratio) * refH
let baseH = min(totalBarH, refH)
if baseH > 0 {
let rect = CGRect(
x: slotX,
y: h - padB - baseH,
width: slotW - 1,
height: baseH)
context.fill(Path(rect), with: .color(barColor.opacity(0.17)))
}
// Overage segment above ideal reference line
if totalBarH > refH {
let overH = totalBarH - refH
let rect = CGRect(
x: slotX,
y: h - padB - totalBarH,
width: slotW - 1,
height: overH)
context.fill(Path(rect), with: .color(barColor.opacity(0.34)))
}
} else if slotStart < tNow {
// Current (partial) period narrower bar ending at tNow
let partialFrac = (tNow - slotStart) / (slotEnd - slotStart)
let consumed = burnRate * (tNow - slotStart)
let ratio = consumed / idealPerPeriod
let totalBarH = CGFloat(ratio) * refH
let baseH = min(totalBarH, refH)
let barW = CGFloat(partialFrac) * (slotW - 1)
if baseH > 0 {
let rect = CGRect(
x: slotX,
y: h - padB - baseH,
width: barW,
height: baseH)
context.fill(Path(rect), with: .color(barColor.opacity(0.13)))
}
if totalBarH > refH {
let overH = totalBarH - refH
let rect = CGRect(
x: slotX,
y: h - padB - totalBarH,
width: barW,
height: overH)
context.fill(Path(rect), with: .color(barColor.opacity(0.26)))
}
} else {
// Future period faint full-height placeholder
let rect = CGRect(
x: slotX,
y: h - padB - refH,
width: slotW - 1,
height: refH)
context.fill(Path(rect), with: .color(barColor.opacity(0.045)))
}
}
// --- 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)
}
// --- Ideal line (dashed, recedes) ---
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, dominant) ---
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, ringed) ---
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: - Compact reset time helper
/// Formats a reset date compactly: "4:30p", "5p", "Sun 9a".
/// Weekday prefix is added for the 7-day window or when the reset is 20h away.
private func combinedCompactResetTime(_ date: Date, isDailyWindow: Bool) -> String {
let includeDay = isDailyWindow || date.timeIntervalSinceNow >= 20 * 3600
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate(includeDay ? "EEEjm" : "jm")
return formatter.string(from: date)
}