Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion ClaudeMeter/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ struct AppSettings: Codable, Equatable, Sendable {
/// Whether menu bar icons are shown in color instead of monochrome.
var isColoredIcon: Bool

/// Days per week the weekly quota is expected to be consumed over (5-7).
/// Sustainable weekly pace is measured against this instead of all 7 days.
var weeklyPaceDays: Int

/// Whether pace (burn rate) is the primary display instead of quota percentage,
/// in both the menu bar and the popover.
var isPaceFirstDisplay: Bool

static let `default` = AppSettings(
refreshInterval: 60,
hasNotificationsEnabled: true,
Expand All @@ -41,7 +49,9 @@ struct AppSettings: Codable, Equatable, Sendable {
cachedOrganizationId: nil,
isSonnetUsageShown: false,
iconStyle: .battery,
isColoredIcon: true
isColoredIcon: true,
weeklyPaceDays: 7,
isPaceFirstDisplay: false
)

enum CodingKeys: String, CodingKey {
Expand All @@ -53,6 +63,8 @@ struct AppSettings: Codable, Equatable, Sendable {
case isSonnetUsageShown = "show_sonnet_usage"
case iconStyle = "icon_style"
case isColoredIcon = "is_colored_icon"
case weeklyPaceDays = "weekly_pace_days"
case isPaceFirstDisplay = "pace_first_display"
}
}

Expand All @@ -69,6 +81,9 @@ extension AppSettings {
isSonnetUsageShown = try container.decodeIfPresent(Bool.self, forKey: .isSonnetUsageShown) ?? defaults.isSonnetUsageShown
iconStyle = try container.decodeIfPresent(IconStyle.self, forKey: .iconStyle) ?? defaults.iconStyle
isColoredIcon = try container.decodeIfPresent(Bool.self, forKey: .isColoredIcon) ?? defaults.isColoredIcon
let decodedPaceDays = try container.decodeIfPresent(Int.self, forKey: .weeklyPaceDays) ?? defaults.weeklyPaceDays
weeklyPaceDays = max(5, min(7, decodedPaceDays))
isPaceFirstDisplay = try container.decodeIfPresent(Bool.self, forKey: .isPaceFirstDisplay) ?? defaults.isPaceFirstDisplay
}
}

Expand All @@ -77,4 +92,9 @@ extension AppSettings {
mutating func setRefreshInterval(_ interval: TimeInterval) {
refreshInterval = max(60, min(600, interval))
}

/// Span the weekly quota is expected to be consumed over, per `weeklyPaceDays`.
var weeklyPacingDuration: TimeInterval {
Constants.Pacing.weeklyPacingDuration(days: weeklyPaceDays)
}
}
27 changes: 25 additions & 2 deletions ClaudeMeter/Models/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,31 @@ enum Constants {
/// 7-day weekly window duration
static let weeklyWindow: TimeInterval = 7 * 24 * 60 * 60

/// Ratio threshold for "at risk" status (using faster than sustainable)
static let riskThreshold: Double = 1.2
/// Ratio threshold for "at risk"/overuse status: any burn above the
/// sustainable line (1.0 = on track to reach the limit exactly at reset).
static let riskThreshold: Double = 1.0

/// Ratio threshold for underuse status (weekly quota likely left unused)
static let underuseThreshold: Double = 0.8

/// Ratio threshold above which overuse is shown as heavy (red instead of orange)
static let heavyOveruseThreshold: Double = 1.2

/// Minimum fraction of the window that must have elapsed before pace is meaningful
/// (avoids ratio noise right after a reset)
static let minimumElapsedFraction: Double = 0.05

/// Minimum utilization before pace projections surface. Below this, an early
/// front-loaded burst is treated as noise; at or above it the pace ratio,
/// projected end, and lockout warning all surface immediately, without
/// waiting out `minimumElapsedFraction`.
static let minimumUsageForProjection: Double = 2.0

/// Converts a weekly pace-days setting (5-7) into the span, in seconds, the
/// weekly quota is expected to be consumed over.
static func weeklyPacingDuration(days: Int) -> TimeInterval {
TimeInterval(days) * 24 * 60 * 60
}
}

/// Usage threshold configuration
Expand Down
58 changes: 58 additions & 0 deletions ClaudeMeter/Models/PaceSignal.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// PaceSignal.swift
// ClaudeMeter
//
// Created by Edd on 2026-07-17.
//

import Foundation

/// Direction of an off-pace burn rate
enum PaceKind: String, Sendable {
/// Burning faster than sustainable - likely to hit the limit before reset
case hot
/// Underusing - quota likely to go unused before reset
case cold
}

/// An off-pace signal for menu bar display, naming the window that produced it
struct PaceSignal: Equatable, Sendable {
let kind: PaceKind

/// Usage fraction divided by elapsed-time fraction (1.0 = sustainable pace)
let ratio: Double

/// Human-readable window name (e.g. "5-hour", "7-day")
let windowName: String

/// Actual utilization percentage
let usedPercent: Double

/// Utilization percentage the pace plan expected by now
let expectedPercent: Double

/// Weekly pace basis in days, when the quota is paced over fewer days than the window
var paceDays: Int?
}

extension PaceSignal {
/// Tooltip text explaining the signal and which window drives it
var tooltip: String {
let used = Int(usedPercent.rounded())
let expected = Int(expectedPercent.rounded())
let formattedRatio = String(format: "%.1f", ratio)
let window: String
if let paceDays, paceDays != 7 {
window = "\(paceDays)/7-day window"
} else {
window = "\(windowName) window"
}

switch kind {
case .hot:
return "Used \(used)% vs \(expected)% expected by now - burning \(formattedRatio)× sustainable pace (\(window))"
case .cold:
return "Used \(used)% vs \(expected)% expected by now - \(formattedRatio)× pace, quota may go unused (\(window))"
}
}
}
75 changes: 75 additions & 0 deletions ClaudeMeter/Models/UsageData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,79 @@ extension UsageData {
var isStale: Bool {
Date().timeIntervalSince(lastUpdated) > Constants.Refresh.stalenessThreshold
}

/// Off-pace signal for the menu bar badge.
/// Hot when either window burns faster than sustainable (highest ratio wins,
/// since an imminent lockout matters more than long-term underuse). Cold only
/// when the weekly window is underused - idle time within the short session
/// window is not a meaningful underuse signal.
/// - Parameter weeklyPaceDays: Days per week the weekly quota is expected to
/// be consumed over (5-7); sustainable weekly pace is measured against this.
func paceSignal(weeklyPaceDays: Int) -> PaceSignal? {
let weeklyPacing = Constants.Pacing.weeklyPacingDuration(days: weeklyPaceDays)
let sessionRatio = sessionUsage.paceRatio(windowDuration: Constants.Pacing.sessionWindow)
let weeklyRatio = weeklyUsage.paceRatio(
windowDuration: Constants.Pacing.weeklyWindow,
pacingDuration: weeklyPacing
)

var hotSignals: [PaceSignal] = []
if let sessionRatio, sessionRatio > Constants.Pacing.riskThreshold {
hotSignals.append(makeSignal(
.hot, limit: sessionUsage, ratio: sessionRatio, windowName: "5-hour",
windowDuration: Constants.Pacing.sessionWindow
))
}
if let weeklyRatio, weeklyRatio > Constants.Pacing.riskThreshold {
hotSignals.append(makeSignal(
.hot, limit: weeklyUsage, ratio: weeklyRatio, windowName: "7-day",
windowDuration: Constants.Pacing.weeklyWindow, pacingDuration: weeklyPacing, paceDays: weeklyPaceDays
))
}
if let hottest = hotSignals.max(by: { $0.ratio < $1.ratio }) {
return hottest
}

if let weeklyRatio, weeklyRatio < Constants.Pacing.underuseThreshold {
return makeSignal(
.cold, limit: weeklyUsage, ratio: weeklyRatio, windowName: "7-day",
windowDuration: Constants.Pacing.weeklyWindow, pacingDuration: weeklyPacing, paceDays: weeklyPaceDays
)
}

return nil
}

/// Ratio to lead the menu bar with in pace-first mode when no off-pace
/// signal fires: the higher — "worst", i.e. closest to or furthest past a
/// sustainable pace — of the session and weekly ratios, so an on-pace weekly
/// isn't hidden behind an idle session. `nil` when neither window has a
/// ratio yet (both inside the grace period / post-reset).
func fallbackPaceRatio(weeklyPaceDays: Int) -> Double? {
let sessionRatio = sessionUsage.paceRatio(windowDuration: Constants.Pacing.sessionWindow)
let weeklyRatio = weeklyUsage.paceRatio(
windowDuration: Constants.Pacing.weeklyWindow,
pacingDuration: Constants.Pacing.weeklyPacingDuration(days: weeklyPaceDays)
)
return [sessionRatio, weeklyRatio].compactMap { $0 }.max()
}

private func makeSignal(
_ kind: PaceKind,
limit: UsageLimit,
ratio: Double,
windowName: String,
windowDuration: TimeInterval,
pacingDuration: TimeInterval? = nil,
paceDays: Int? = nil
) -> PaceSignal {
PaceSignal(
kind: kind,
ratio: ratio,
windowName: windowName,
usedPercent: limit.utilization,
expectedPercent: limit.expectedUsagePercent(windowDuration: windowDuration, pacingDuration: pacingDuration) ?? 0,
paceDays: paceDays
)
}
}
95 changes: 87 additions & 8 deletions ClaudeMeter/Models/UsageLimit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,99 @@ extension UsageLimit {
resetAt < Date() && utilization > 0
}

/// Ratio of usage fraction to elapsed-time fraction of the window.
/// 1.0 = exactly sustainable pace, >1 = burning faster, <1 = underusing.
/// Returns nil when the window isn't active, or too little has elapsed and
/// usage is still below `minimumUsageForProjection` (a front-loaded burst
/// surfaces the ratio without waiting out the elapsed grace).
/// - Parameters:
/// - windowDuration: Duration of the usage window (e.g., 5 hours for session)
/// - pacingDuration: Time span the quota is expected to be consumed over.
/// Defaults to the full window; a shorter span (e.g., 5 working days of a
/// 7-day window) expects the quota to be burned faster. Elapsed time is
/// capped at the pacing duration, so past it the ratio equals the usage fraction.
func paceRatio(windowDuration: TimeInterval, pacingDuration: TimeInterval? = nil) -> Double? {
guard let expected = expectedUsagePercent(windowDuration: windowDuration, pacingDuration: pacingDuration) else {
return nil
}
return min(utilization, 100) / expected
}

/// Utilization percentage the pace plan expects by now (0-100), i.e. the
/// elapsed fraction of the pacing span. Returns nil under the same
/// conditions as `paceRatio(windowDuration:pacingDuration:)`.
func expectedUsagePercent(windowDuration: TimeInterval, pacingDuration: TimeInterval? = nil) -> Double? {
let pacing = pacingDuration ?? windowDuration
let now = Date()
guard resetAt > now, pacing > 0 else { return nil }

let windowStart = resetAt.addingTimeInterval(-windowDuration)
let timeElapsedPct = min(now.timeIntervalSince(windowStart) / pacing, 1.0)
// A front-loaded burst is meaningful before the elapsed grace: once usage
// clears `minimumUsageForProjection` the ratio surfaces immediately, matching
// `projectedLimitDate`. `timeElapsedPct > 0` keeps the ratio's divisor safe.
guard timeElapsedPct > 0,
timeElapsedPct >= Constants.Pacing.minimumElapsedFraction
|| utilization >= Constants.Pacing.minimumUsageForProjection
else { return nil }

return timeElapsedPct * 100
}

/// Returns true if current usage rate will likely exceed limit before reset
/// - Parameter windowDuration: Duration of the usage window (e.g., 5 hours for session)
func isAtRisk(windowDuration: TimeInterval) -> Bool {
/// - Parameters:
/// - windowDuration: Duration of the usage window (e.g., 5 hours for session)
/// - pacingDuration: See `paceRatio(windowDuration:pacingDuration:)`
func isAtRisk(windowDuration: TimeInterval, pacingDuration: TimeInterval? = nil) -> Bool {
guard let ratio = paceRatio(windowDuration: windowDuration, pacingDuration: pacingDuration) else { return false }
return ratio > Constants.Pacing.riskThreshold
}

/// Projected utilization percentage at the pacing deadline if the current
/// average rate holds. Extrapolates to the pacing horizon (default: the full
/// window) so it shares a time basis with `paceRatio` — a card can't then read
/// "underusing" and "hits limit" at once. Returns nil when the window isn't
/// active, or too little has elapsed and usage is below
/// `minimumUsageForProjection`.
/// - Parameters:
/// - windowDuration: Duration of the usage window (e.g., 5 hours for session)
/// - pacingDuration: See `paceRatio(windowDuration:pacingDuration:)`
func projectedEndPercent(windowDuration: TimeInterval, pacingDuration: TimeInterval? = nil) -> Double? {
let now = Date()
guard resetAt > now else { return false }
guard resetAt > now else { return nil }

let windowStart = resetAt.addingTimeInterval(-windowDuration)
let elapsed = now.timeIntervalSince(windowStart)
guard elapsed > 0 else { return false }
guard elapsed > 0 else { return nil }
// As with `projectedLimitDate`, a burst clearing `minimumUsageForProjection`
// projects immediately instead of waiting out the elapsed grace window.
guard elapsed >= windowDuration * Constants.Pacing.minimumElapsedFraction
|| utilization >= Constants.Pacing.minimumUsageForProjection
else { return nil }

// Never project a horizon shorter than what's already elapsed.
let horizon = max(pacingDuration ?? windowDuration, elapsed)
return utilization * (horizon / elapsed)
}

/// When the limit will be hit at the current average rate, if that lands on or
/// before the pacing deadline. Returns nil if usage won't reach 100% in time
/// (or already has). Unlike `projectedEndPercent`, this fires as soon as usage
/// clears `minimumUsageForProjection` — a genuine front-loaded burst warns
/// immediately rather than waiting out the elapsed-time grace window.
func projectedLimitDate(windowDuration: TimeInterval, pacingDuration: TimeInterval? = nil) -> Date? {
let now = Date()
guard !isExceeded, utilization >= Constants.Pacing.minimumUsageForProjection, resetAt > now else {
return nil
}

let timeElapsedPct = elapsed / windowDuration
let usagePct = min(utilization, 100) / 100
guard timeElapsedPct > 0 else { return false }
let windowStart = resetAt.addingTimeInterval(-windowDuration)
let elapsed = now.timeIntervalSince(windowStart)
guard elapsed > 0 else { return nil }

return (usagePct / timeElapsedPct) > Constants.Pacing.riskThreshold
let hitDate = windowStart.addingTimeInterval(elapsed * 100 / utilization)
let deadline = windowStart.addingTimeInterval(max(pacingDuration ?? windowDuration, elapsed))
guard hitDate < deadline else { return nil }
return hitDate
}
}
Loading