diff --git a/ClaudeMeter/Models/AppSettings.swift b/ClaudeMeter/Models/AppSettings.swift index 3522384..75e7c86 100644 --- a/ClaudeMeter/Models/AppSettings.swift +++ b/ClaudeMeter/Models/AppSettings.swift @@ -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, @@ -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 { @@ -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" } } @@ -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 } } @@ -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) + } } diff --git a/ClaudeMeter/Models/Constants.swift b/ClaudeMeter/Models/Constants.swift index 1bec4e1..4abcd96 100644 --- a/ClaudeMeter/Models/Constants.swift +++ b/ClaudeMeter/Models/Constants.swift @@ -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 diff --git a/ClaudeMeter/Models/PaceSignal.swift b/ClaudeMeter/Models/PaceSignal.swift new file mode 100644 index 0000000..aab0473 --- /dev/null +++ b/ClaudeMeter/Models/PaceSignal.swift @@ -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))" + } + } +} diff --git a/ClaudeMeter/Models/UsageData.swift b/ClaudeMeter/Models/UsageData.swift index 78e62fb..6856131 100644 --- a/ClaudeMeter/Models/UsageData.swift +++ b/ClaudeMeter/Models/UsageData.swift @@ -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 + ) + } } diff --git a/ClaudeMeter/Models/UsageLimit.swift b/ClaudeMeter/Models/UsageLimit.swift index 847dd97..7e0345e 100644 --- a/ClaudeMeter/Models/UsageLimit.swift +++ b/ClaudeMeter/Models/UsageLimit.swift @@ -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 } } diff --git a/ClaudeMeter/Views/MenuBar/IconCache.swift b/ClaudeMeter/Views/MenuBar/IconCache.swift index 49c0e65..e60fffd 100644 --- a/ClaudeMeter/Views/MenuBar/IconCache.swift +++ b/ClaudeMeter/Views/MenuBar/IconCache.swift @@ -22,7 +22,9 @@ final class IconCache { isStale: Bool, iconStyle: IconStyle, weeklyPercentage: Double, - isColored: Bool + isColored: Bool, + paceKind: PaceKind?, + paceRatio: Double? ) -> NSImage? { cache.object(forKey: cacheKey( percentage: percentage, @@ -31,7 +33,9 @@ final class IconCache { isStale: isStale, iconStyle: iconStyle, weeklyPercentage: weeklyPercentage, - isColored: isColored + isColored: isColored, + paceKind: paceKind, + paceRatio: paceRatio )) } @@ -43,7 +47,9 @@ final class IconCache { isStale: Bool, iconStyle: IconStyle, weeklyPercentage: Double, - isColored: Bool + isColored: Bool, + paceKind: PaceKind?, + paceRatio: Double? ) { cache.setObject( image, @@ -54,7 +60,9 @@ final class IconCache { isStale: isStale, iconStyle: iconStyle, weeklyPercentage: weeklyPercentage, - isColored: isColored + isColored: isColored, + paceKind: paceKind, + paceRatio: paceRatio ) ) } @@ -66,10 +74,19 @@ final class IconCache { isStale: Bool, iconStyle: IconStyle, weeklyPercentage: Double, - isColored: Bool + isColored: Bool, + paceKind: PaceKind?, + paceRatio: Double? ) -> NSString { let percent = String(format: "%.2f", percentage) let weekly = String(format: "%.2f", weeklyPercentage) - return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)|\(isColored)" as NSString + let pace = paceKind?.rawValue ?? "none" + // The displayed ratio is rounded to 1 decimal, but its rendered color is a + // function of the full-precision value. Two ratios that round equal can + // straddle a PacePalette threshold (e.g. 2.49 vs 2.53 -> orange vs red), so + // the color band must be part of the key or they'd collide on a stale image. + let ratio = paceRatio.map { String(format: "%.1f", $0) } ?? "none" + let band = paceRatio.map { PacePalette.band(for: $0).rawValue } ?? "none" + return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)|\(isColored)|\(pace)|\(ratio)|\(band)" as NSString } } diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift index dc3bbaa..c5497b0 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift @@ -13,6 +13,8 @@ struct BatteryIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var overrideText: String? // Replaces the percentage text (pace-first display) + var overrideColor: Color? // Color for the override text private let capsuleWidth: CGFloat = 28 private let capsuleHeight: CGFloat = 10 @@ -36,10 +38,10 @@ struct BatteryIcon: View { } .frame(width: capsuleWidth, height: capsuleHeight) - // Percentage text - Text("\(Int(percentage))%") + // Percentage (or pace) text + Text(overrideText ?? "\(Int(percentage))%") .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundColor(statusColor) + .foregroundColor(textColor) } if isStale && !isLoading { @@ -74,6 +76,10 @@ struct BatteryIcon: View { private var statusColor: Color { isStale ? .gray : status.color } + + private var textColor: Color { + IconPalette.textColor(isStale: isStale, override: overrideColor, status: status) + } } #Preview { diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift index 446750b..c597a05 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift @@ -13,6 +13,8 @@ struct CircularGaugeIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var overrideText: String? // Replaces the percentage text (pace-first display) + var overrideColor: Color? // Color for the override text private let lineWidth: CGFloat = 3 private let size: CGFloat = 18 @@ -35,9 +37,9 @@ struct CircularGaugeIcon: View { .font(.system(size: 7, weight: .medium)) .foregroundColor(statusColor) } else { - Text("\(Int(percentage))") + Text(overrideText ?? "\(Int(percentage))") .font(.system(size: 7, weight: .bold, design: .rounded)) - .foregroundColor(statusColor) + .foregroundColor(textColor) } } .frame(width: size, height: size) @@ -58,6 +60,10 @@ struct CircularGaugeIcon: View { private var statusColor: Color { isStale ? .gray : status.color } + + private var textColor: Color { + IconPalette.textColor(isStale: isStale, override: overrideColor, status: status) + } } #Preview { diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift index 278704e..a1ef2a0 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift @@ -14,6 +14,8 @@ struct DualBarIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var overrideText: String? // Replaces the percentage text (pace-first display) + var overrideColor: Color? // Color for the override text private let barWidth: CGFloat = 32 private let barHeight: CGFloat = 5 @@ -45,10 +47,10 @@ struct DualBarIcon: View { .frame(width: barWidth, height: barHeight) } - // Show session percentage (primary metric) - Text("\(Int(percentage))%") + // Show session percentage (or pace, primary metric) + Text(overrideText ?? "\(Int(percentage))%") .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundColor(statusColor) + .foregroundColor(textColor) } if isStale && !isLoading { @@ -67,6 +69,10 @@ struct DualBarIcon: View { isStale ? .gray : status.color } + private var textColor: Color { + IconPalette.textColor(isStale: isStale, override: overrideColor, status: status) + } + private var sessionBarColor: Color { if isStale { return .gray } // Use status color for session bar diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift index 539b15e..28f53bf 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift @@ -13,6 +13,7 @@ struct GaugeIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var overrideColor: Color? // Pace color for the needle (pace-first display) var body: some View { HStack(spacing: 4) { @@ -61,7 +62,7 @@ struct GaugeIcon: View { } private var statusColor: Color { - isStale ? .gray : status.color + IconPalette.textColor(isStale: isStale, override: overrideColor, status: status) } } diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift index a55e0e9..174d477 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift @@ -13,6 +13,8 @@ struct MinimalIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var overrideText: String? // Replaces the percentage text (pace-first display) + var overrideColor: Color? // Color for the override text var body: some View { HStack(spacing: 2) { @@ -21,9 +23,9 @@ struct MinimalIcon: View { .font(.system(size: 11, weight: .medium)) .foregroundColor(statusColor) } else { - Text("\(Int(percentage))%") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundColor(statusColor) + Text(overrideText ?? "\(Int(percentage))%") + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) } if isStale && !isLoading { @@ -41,6 +43,10 @@ struct MinimalIcon: View { private var statusColor: Color { isStale ? .gray : status.color } + + private var textColor: Color { + IconPalette.textColor(isStale: isStale, override: overrideColor, status: status) + } } #Preview { diff --git a/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift b/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift index 2f571e8..7c6e6c0 100644 --- a/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift +++ b/ClaudeMeter/Views/MenuBar/IconStyles/SegmentedBarIcon.swift @@ -13,6 +13,7 @@ struct SegmentedBarIcon: View { let status: UsageStatus let isLoading: Bool let isStale: Bool + var overrideColor: Color? // Pace color for the active segments (pace-first display) private let segmentCount = 5 private let segmentWidth: CGFloat = 4 @@ -60,6 +61,10 @@ struct SegmentedBarIcon: View { if isStale { return .gray } + // Pace-first display: every active segment reflects the single pace color + if let overrideColor { + return overrideColor + } // Color segments by position to create a gradient effect (green → orange → red) // Uses Constants.Thresholds.Status for consistent color boundaries let segmentPercentage = Double(index + 1) / Double(segmentCount) * 100 diff --git a/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift b/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift index dd1f602..aa6356a 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift @@ -18,7 +18,9 @@ struct MenuBarIconRenderer { isStale: Bool, iconStyle: IconStyle, weeklyPercentage: Double = 0, - isColored: Bool = true + isColored: Bool = true, + paceKind: PaceKind? = nil, + paceRatio: Double? = nil ) -> NSImage { let iconView = MenuBarIconView( percentage: percentage, @@ -26,7 +28,9 @@ struct MenuBarIconRenderer { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + paceKind: paceKind, + paceRatio: paceRatio ) let renderer = ImageRenderer(content: iconView) diff --git a/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift b/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift index a6993c6..147ec54 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift @@ -15,21 +15,62 @@ struct MenuBarIconView: View { let isStale: Bool let iconStyle: IconStyle var weeklyPercentage: Double = 0 // Optional, used by dualBar style + var paceKind: PaceKind? // Optional off-pace badge (flame/snowflake) + var paceRatio: Double? // Pace-first display: replaces the quota text with this ratio + + private var paceText: String? { + paceRatio.map { String(format: "%.1f×", $0) } + } + + /// Compact variant without the multiply sign, for the tiny circular gauge center + private var compactPaceText: String? { + paceRatio.map { String(format: "%.1f", $0) } + } + + private var paceColor: Color? { + paceRatio.map(PacePalette.color(for:)) + } + + /// Off-pace badge color: grayed out when data is stale (matching the rest of + /// the icon), otherwise the shared pace scale so a heavy overuse reads red like + /// the popover rather than a fixed orange. + private func badgeColor(for kind: PaceKind) -> Color { + if isStale { return .gray } + return paceColor ?? (kind == .hot ? .orange : .blue) + } var body: some View { + HStack(spacing: 2) { + styleView + + if let paceKind, !isLoading { + Image(systemName: paceKind == .hot ? "flame.fill" : "snowflake") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(badgeColor(for: paceKind)) + .accessibilityLabel( + paceKind == .hot + ? "Burning faster than sustainable pace" + : "Weekly quota may go unused" + ) + } + } + } + + @ViewBuilder + private var styleView: some View { switch iconStyle { case .battery: - BatteryIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + BatteryIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, overrideText: paceText, overrideColor: paceColor) case .circular: - CircularGaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + CircularGaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, overrideText: compactPaceText, overrideColor: paceColor) case .minimal: - MinimalIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + MinimalIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, overrideText: paceText, overrideColor: paceColor) case .segments: - SegmentedBarIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + SegmentedBarIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, overrideColor: paceColor) case .dualBar: - DualBarIcon(percentage: percentage, weeklyPercentage: weeklyPercentage, status: status, isLoading: isLoading, isStale: isStale) + DualBarIcon(percentage: percentage, weeklyPercentage: weeklyPercentage, status: status, isLoading: isLoading, isStale: isStale, overrideText: paceText, overrideColor: paceColor) case .gauge: - GaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) + GaugeIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, overrideColor: paceColor) } } } diff --git a/ClaudeMeter/Views/MenuBar/MenuBarManager.swift b/ClaudeMeter/Views/MenuBar/MenuBarManager.swift index fbb6a6f..588fd59 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarManager.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarManager.swift @@ -54,6 +54,9 @@ final class MenuBarManager { // MARK: - Setup private func setupStatusItem() { + // Show tooltips (e.g. the pace explanation) quickly instead of the ~2s system default + UserDefaults.standard.set(300, forKey: "NSInitialToolTipDelay") + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) guard let button = statusItem?.button else { return } @@ -100,6 +103,8 @@ final class MenuBarManager { _ = appModel.isLoading _ = appModel.settings.iconStyle _ = appModel.settings.isColoredIcon + _ = appModel.settings.weeklyPaceDays + _ = appModel.settings.isPaceFirstDisplay } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } @@ -119,6 +124,21 @@ final class MenuBarManager { let isLoading = appModel.isLoading let style = appModel.settings.iconStyle let isColored = appModel.settings.isColoredIcon + let settings = appModel.settings + + // Pace-first display: replace the quota text with the most relevant ratio - + // the off-pace signal's if there is one, else the worst (highest) of the + // window ratios, so an on-pace weekly isn't hidden behind an idle session. + // Skip the pace computation entirely when the feature is off. + var paceSignal: PaceSignal? + var paceRatio: Double? + if settings.isPaceFirstDisplay, let data = appModel.usageData { + paceSignal = data.paceSignal(weeklyPaceDays: settings.weeklyPaceDays) + paceRatio = paceSignal?.ratio + ?? data.fallbackPaceRatio(weeklyPaceDays: settings.weeklyPaceDays) + } + + button.toolTip = paceSignal?.tooltip if let cachedImage = iconCache.get( percentage: percentage, @@ -127,7 +147,9 @@ final class MenuBarManager { isStale: isStale, iconStyle: style, weeklyPercentage: weeklyPercentage, - isColored: isColored + isColored: isColored, + paceKind: paceSignal?.kind, + paceRatio: paceRatio ) { button.image = cachedImage return @@ -140,7 +162,9 @@ final class MenuBarManager { isStale: isStale, iconStyle: style, weeklyPercentage: weeklyPercentage, - isColored: isColored + isColored: isColored, + paceKind: paceSignal?.kind, + paceRatio: paceRatio ) iconCache.set( @@ -151,7 +175,9 @@ final class MenuBarManager { isStale: isStale, iconStyle: style, weeklyPercentage: weeklyPercentage, - isColored: isColored + isColored: isColored, + paceKind: paceSignal?.kind, + paceRatio: paceRatio ) button.image = image diff --git a/ClaudeMeter/Views/MenuBar/PacePalette.swift b/ClaudeMeter/Views/MenuBar/PacePalette.swift new file mode 100644 index 0000000..912ca15 --- /dev/null +++ b/ClaudeMeter/Views/MenuBar/PacePalette.swift @@ -0,0 +1,45 @@ +// +// PacePalette.swift +// ClaudeMeter +// +// Created by Edd on 2026-07-17. +// + +import SwiftUI + +/// Shared color scale for pace ratios, used by the menu bar and popover alike +enum PacePalette { + /// Discrete pace band a ratio falls into. The single source of truth for the + /// ratio thresholds so color, cache keys, and callers can't drift apart. + enum Band: String { + case underuse, sustainable, overuse, heavyOveruse + } + + /// Blue underuse (<0.8x), green sustainable (0.8-1.0x), + /// orange overuse (1.0-1.2x), red heavy overuse (>1.2x) + static func band(for ratio: Double) -> Band { + if ratio < Constants.Pacing.underuseThreshold { return .underuse } + if ratio <= Constants.Pacing.riskThreshold { return .sustainable } + if ratio <= Constants.Pacing.heavyOveruseThreshold { return .overuse } + return .heavyOveruse + } + + static func color(for ratio: Double) -> Color { + switch band(for: ratio) { + case .underuse: return .blue + case .sustainable: return .green + case .overuse: return .orange + case .heavyOveruse: return .red + } + } +} + +/// Shared resolution of an icon label's color across the menu bar icon styles: +/// gray when stale, the pace override color when one is active, else quota status. +enum IconPalette { + static func textColor(isStale: Bool, override: Color?, status: UsageStatus) -> Color { + if isStale { return .gray } + if let override { return override } + return status.color + } +} diff --git a/ClaudeMeter/Views/MenuBar/UsageCardView.swift b/ClaudeMeter/Views/MenuBar/UsageCardView.swift index cc425c5..95c0da4 100644 --- a/ClaudeMeter/Views/MenuBar/UsageCardView.swift +++ b/ClaudeMeter/Views/MenuBar/UsageCardView.swift @@ -13,6 +13,27 @@ struct UsageCardView: View { let usageLimit: UsageLimit let icon: String let windowDuration: TimeInterval? + var pacingDuration: TimeInterval? // Optional shorter span the quota is expected to be consumed over + var showsUnderuse: Bool = false // Whether underuse is a meaningful signal for this window (weekly, not session) + var isPaceFirst: Bool = false // Pace as primary display, quota as secondary + + private var paceRatio: Double? { + guard let windowDuration else { return nil } + return usageLimit.paceRatio(windowDuration: windowDuration, pacingDuration: pacingDuration) + } + + private var expectedPercent: Double? { + guard let windowDuration else { return nil } + return usageLimit.expectedUsagePercent(windowDuration: windowDuration, pacingDuration: pacingDuration) + } + + /// Bar fill color: pace scale in pace-first mode, quota status otherwise + private var barColor: Color { + if isPaceFirst, let paceRatio { + return PacePalette.color(for: paceRatio) + } + return usageLimit.status.color + } var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -28,62 +49,109 @@ struct UsageCardView: View { Spacer() - // Status badge - HStack(spacing: 4) { - Image(systemName: usageLimit.status.iconName) - .font(.caption) - Text(usageLimit.status.rawValue.capitalized) - .font(.caption) - .fontWeight(.medium) + // Status badge: pace verdict in pace-first mode, quota status otherwise + if isPaceFirst, let paceRatio { + let verdict = Self.paceVerdict(for: paceRatio) + HStack(spacing: 4) { + Image(systemName: verdict.icon) + .font(.caption) + Text(verdict.label) + .font(.caption) + .fontWeight(.medium) + } + .foregroundColor(verdict.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(verdict.color.opacity(0.15)) + .cornerRadius(8) + } else { + HStack(spacing: 4) { + Image(systemName: usageLimit.status.iconName) + .font(.caption) + Text(usageLimit.status.rawValue.capitalized) + .font(.caption) + .fontWeight(.medium) + } + .foregroundColor(usageLimit.status.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(usageLimit.status.color.opacity(0.15)) + .cornerRadius(8) } - .foregroundColor(usageLimit.status.color) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(usageLimit.status.color.opacity(0.15)) - .cornerRadius(8) } - // Usage percentage - Text("\(Int(usageLimit.percentage))%") - .font(.system(size: 36, weight: .bold, design: .rounded)) - .foregroundColor(usageLimit.status.color) + // Primary number: pace ratio in pace-first mode, quota percentage otherwise + HStack(alignment: .lastTextBaseline) { + if isPaceFirst, let paceRatio { + Text(String(format: "%.1f×", paceRatio)) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundColor(Self.paceVerdict(for: paceRatio).color) + + Spacer() + + // Quota usage as secondary detail + VStack(alignment: .trailing, spacing: 2) { + Text("\(Int(usageLimit.percentage))% used") + .font(.caption) + .foregroundColor(usageLimit.status.color) + if let expectedPercent { + Text("\(Int(expectedPercent.rounded()))% expected") + .font(.caption) + .foregroundColor(.secondary) + } + } + } else { + Text("\(Int(usageLimit.percentage))%") + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundColor(usageLimit.status.color) + + Spacer() - // Progress bar + VStack(alignment: .trailing, spacing: 2) { + if let expectedPercent { + Text("\(Int(expectedPercent.rounded()))% expected") + .font(.caption) + .foregroundColor(.secondary) + } + paceLine + } + } + } + + // Progress bar with expected-pace tick GeometryReader { geometry in ZStack(alignment: .leading) { // Background RoundedRectangle(cornerRadius: 4) .fill(Color.gray.opacity(0.2)) - // Progress + // Progress (pace-colored in pace-first mode, quota-status otherwise) RoundedRectangle(cornerRadius: 4) - .fill(usageLimit.status.color) + .fill(barColor) .frame(width: geometry.size.width * min(usageLimit.percentage / 100, 1.0)) + + // Expected-by-now tick + if let expectedPercent { + RoundedRectangle(cornerRadius: 1) + .fill(Color.primary.opacity(0.55)) + .frame(width: 2, height: 14) + .offset(x: geometry.size.width * min(expectedPercent / 100, 1.0) - 1) + } } } .frame(height: 8) - // Reset time and pacing indicator - HStack(spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "clock") - .font(.caption) - Text("Resets \(usageLimit.resetDescription)") - .font(.caption) - } - .help(usageLimit.resetTimeFormatted) - - Spacer() + // Projection at the current rate + projectionLine - if let windowDuration, - usageLimit.isAtRisk(windowDuration: windowDuration) { - Image(systemName: "flame.fill") - .font(.caption) - .foregroundColor(.orange) - .help("You may hit your limit before it resets") - .accessibilityLabel("At risk of hitting limit") - } + // Reset time + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.caption) + Text("Resets \(usageLimit.resetDescription)") + .font(.caption) } + .help(usageLimit.resetTimeFormatted) .foregroundColor(.secondary) } .padding(16) @@ -93,6 +161,81 @@ struct UsageCardView: View { .accessibilityLabel("\(title): \(Int(usageLimit.percentage))% used, \(usageLimit.status.accessibilityDescription)") .accessibilityValue("Resets \(usageLimit.resetDescription)") } + + // MARK: - Pace + + @ViewBuilder + private var paceLine: some View { + if let paceRatio { + let formatted = String(format: "%.1f×", paceRatio) + HStack(spacing: 3) { + if paceRatio > Constants.Pacing.riskThreshold { + Image(systemName: "flame.fill") + } else if paceRatio < Constants.Pacing.underuseThreshold { + Image(systemName: "snowflake") + } + Text("\(formatted) pace") + } + .font(.caption) + .foregroundColor(PacePalette.color(for: paceRatio)) + .accessibilityLabel("\(formatted) sustainable pace") + } + } + + @ViewBuilder + private var projectionLine: some View { + if usageLimit.isExceeded { + Text("Limit reached") + .font(.caption) + .foregroundColor(.red) + } else if let windowDuration { + if let hitDate = usageLimit.projectedLimitDate(windowDuration: windowDuration, pacingDuration: pacingDuration) { + Text("Hits limit ~\(Self.hitDateDescription(hitDate)), \(Self.remainingDescription(usageLimit.resetAt.timeIntervalSince(hitDate))) before reset") + .font(.caption) + .foregroundColor(paceRatio.map(PacePalette.color(for:)) ?? .orange) + } else if let endPercent = usageLimit.projectedEndPercent(windowDuration: windowDuration, pacingDuration: pacingDuration) { + let end = Int(min(endPercent, 100).rounded()) + if showsUnderuse, let paceRatio, paceRatio < Constants.Pacing.underuseThreshold { + Text("On pace to end at ~\(end)% (\(100 - end)% unused)") + .font(.caption) + .foregroundColor(.blue) + } else { + Text("On pace to end at ~\(end)%") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + /// Pace verdict presentation for pace-first mode + static func paceVerdict(for ratio: Double) -> (label: String, icon: String, color: Color) { + let color = PacePalette.color(for: ratio) + if ratio > Constants.Pacing.riskThreshold { + return ("Overusing", "flame.fill", color) + } + if ratio < Constants.Pacing.underuseThreshold { + return ("Underusing", "snowflake", color) + } + return ("On Pace", "checkmark.circle.fill", color) + } + + /// Time-of-day for a same-day hit; weekday/date + time when the projected hit + /// is days out (weekly window), so a multi-day projection isn't shown as a bare + /// clock time that reads like today. + static func hitDateDescription(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = Calendar.current.isDateInToday(date) ? .none : .short + formatter.doesRelativeDateFormatting = true + return formatter.string(from: date) + } + + /// "in 50 minutes" -> "50 minutes" + private static func remainingDescription(_ interval: TimeInterval) -> String { + let description = UsageLimit.resetDescription(for: interval) + return description.hasPrefix("in ") ? String(description.dropFirst(3)) : description + } } // MARK: - Preview diff --git a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift index 199bf06..d49b7f0 100644 --- a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift +++ b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift @@ -14,6 +14,17 @@ struct UsagePopoverView: View { let onRequestClose: (() -> Void)? @Environment(\.openSettings) private var openSettings + /// Span the weekly quota is expected to be consumed over, per the pace-days setting + private var weeklyPacingDuration: TimeInterval { + appModel.settings.weeklyPacingDuration + } + + /// Appends the pace basis to weekly card titles when it isn't the full week + private func weeklyCardTitle(_ base: String) -> String { + let days = appModel.settings.weeklyPaceDays + return days == 7 ? base : "\(base) (\(days)/7-day)" + } + var body: some View { VStack(spacing: 0) { // Header @@ -92,24 +103,31 @@ struct UsagePopoverView: View { title: "5-Hour Session", usageLimit: usageData.sessionUsage, icon: "gauge.with.dots.needle.67percent", - windowDuration: Constants.Pacing.sessionWindow + windowDuration: Constants.Pacing.sessionWindow, + isPaceFirst: appModel.settings.isPaceFirstDisplay ) // Weekly usage card UsageCardView( - title: "Weekly Usage", + title: weeklyCardTitle("Weekly Usage"), usageLimit: usageData.weeklyUsage, icon: "calendar", - windowDuration: Constants.Pacing.weeklyWindow + windowDuration: Constants.Pacing.weeklyWindow, + pacingDuration: weeklyPacingDuration, + showsUnderuse: true, + isPaceFirst: appModel.settings.isPaceFirstDisplay ) // Sonnet usage card (conditional rendering) if appModel.settings.isSonnetUsageShown, let sonnetUsage = usageData.sonnetUsage { UsageCardView( - title: "Weekly Sonnet", + title: weeklyCardTitle("Weekly Sonnet"), usageLimit: sonnetUsage, icon: "sparkles", - windowDuration: Constants.Pacing.weeklyWindow + windowDuration: Constants.Pacing.weeklyWindow, + pacingDuration: weeklyPacingDuration, + showsUnderuse: true, + isPaceFirst: appModel.settings.isPaceFirstDisplay ) } } @@ -149,7 +167,7 @@ struct UsagePopoverView: View { } .padding() } - .frame(width: 320, height: 460) + .frame(width: 320, height: 510) .background(Color(nsColor: .windowBackgroundColor)) .accessibilityElement(children: .contain) .accessibilityLabel("Usage Dashboard") diff --git a/ClaudeMeter/Views/Settings/DisplayModePicker.swift b/ClaudeMeter/Views/Settings/DisplayModePicker.swift new file mode 100644 index 0000000..cf27014 --- /dev/null +++ b/ClaudeMeter/Views/Settings/DisplayModePicker.swift @@ -0,0 +1,148 @@ +// +// DisplayModePicker.swift +// ClaudeMeter +// +// Created by Edd on 2026-07-17. +// + +import SwiftUI + +/// Two-card picker for quota-first vs pace-first display, each card led by +/// the question that mode answers, with a live menu bar preview as evidence. +struct DisplayModePicker: View { + @Binding var isPaceFirst: Bool + let iconStyle: IconStyle + let isColored: Bool + + // Rasterizing an NSImage is comparatively expensive, so cache the two previews + // and re-render only when the inputs that shape them change - not on every + // unrelated settings re-render. + @State private var consumptionPreview: NSImage? + @State private var pacePreview: NSImage? + + var body: some View { + HStack(spacing: 12) { + DisplayModeCard( + question: "How much have I used?", + name: "Consumption", + why: "Track what you've used of each limit", + isSelected: !isPaceFirst, + preview: consumptionPreview + ) { + isPaceFirst = false + } + + DisplayModeCard( + question: "Am I on track?", + name: "Pace", + why: "Max your plan: no lockouts, no unused quota", + isSelected: isPaceFirst, + preview: pacePreview + ) { + isPaceFirst = true + } + } + .onAppear(perform: renderPreviews) + .onChange(of: iconStyle) { renderPreviews() } + .onChange(of: isColored) { renderPreviews() } + } + + private func renderPreviews() { + let renderer = MenuBarIconRenderer() + consumptionPreview = renderPreview(renderer, paceRatio: nil, paceKind: nil) + pacePreview = renderPreview(renderer, paceRatio: 1.8, paceKind: .hot) + } + + private func renderPreview(_ renderer: MenuBarIconRenderer, paceRatio: Double?, paceKind: PaceKind?) -> NSImage { + renderer.render( + percentage: 65, + status: .warning, + isLoading: false, + isStale: false, + iconStyle: iconStyle, + weeklyPercentage: 45, + isColored: isColored, + paceKind: paceKind, + paceRatio: paceRatio + ) + } +} + +/// One display mode card: question headline, live preview, mode name, and why +struct DisplayModeCard: View { + let question: String + let name: String + let why: String + let isSelected: Bool + let preview: NSImage? + let onSelect: () -> Void + + var body: some View { + VStack(spacing: 8) { + Text("\u{201C}\(question)\u{201D}") + .font(.caption) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color(NSColor.windowBackgroundColor)) + .frame(height: 32) + + if let preview { + Image(nsImage: preview) + .scaleEffect(1.2) + .accessibilityHidden(true) + } + } + + HStack(spacing: 4) { + Text(name) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(isSelected ? .accentColor : .primary) + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(.accentColor) + } + } + + Text(why) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(10) + .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(name) display mode: \(question) \(why)") + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } +} + +// MARK: - Preview + +#Preview { + struct PreviewWrapper: View { + @State private var isPaceFirst = false + + var body: some View { + DisplayModePicker(isPaceFirst: $isPaceFirst, iconStyle: .minimal, isColored: true) + .padding() + .frame(width: 400) + } + } + + return PreviewWrapper() +} diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift index 4fd2588..37c29a9 100644 --- a/ClaudeMeter/Views/Settings/SettingsView.swift +++ b/ClaudeMeter/Views/Settings/SettingsView.swift @@ -62,7 +62,9 @@ struct SettingsView: View { sessionKeySection refreshIntervalSection sonnetUsageSection + displayModeSection iconStyleSection + paceIndicatorSection launchAtLoginSection } } @@ -232,6 +234,29 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + // MARK: - Display Mode Section + + private var displayModeSection: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Display Mode") + .font(.subheadline) + Text("What the menu bar and popover lead with") + .font(.caption) + .foregroundStyle(.secondary) + } + + DisplayModePicker( + isPaceFirst: $appModel.settings.isPaceFirstDisplay, + iconStyle: appModel.settings.iconStyle, + isColored: appModel.settings.isColoredIcon + ) + } + .padding() + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + // MARK: - Icon Style Section private var iconStyleSection: some View { @@ -269,6 +294,35 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + // MARK: - Pace Indicator Section + + private var paceIndicatorSection: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Weekly Pace Basis") + .font(.subheadline) + Text("Days per week you expect to use your weekly quota over") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Picker("Weekly pace basis", selection: $appModel.settings.weeklyPaceDays) { + Text("5 days").tag(5) + Text("6 days").tag(6) + Text("7 days").tag(7) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 200) + .accessibilityLabel("Weekly pace basis in days") + } + .padding() + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + // MARK: - Launch at Login Section private var launchAtLoginSection: some View { diff --git a/ClaudeMeterTests/PaceSignalTests.swift b/ClaudeMeterTests/PaceSignalTests.swift new file mode 100644 index 0000000..23791ef --- /dev/null +++ b/ClaudeMeterTests/PaceSignalTests.swift @@ -0,0 +1,314 @@ +// +// PaceSignalTests.swift +// ClaudeMeterTests +// +// Created by Edd on 2026-07-17. +// + +import XCTest +@testable import ClaudeMeter + +final class PaceSignalTests: XCTestCase { + + private let sessionWindow = Constants.Pacing.sessionWindow + private let weeklyWindow = Constants.Pacing.weeklyWindow + + // MARK: - Helpers + + /// Builds a limit whose window has `elapsedFraction` of `window` elapsed, with `utilization` used. + private func limit(utilization: Double, elapsedFraction: Double, window: TimeInterval) -> UsageLimit { + let remaining = window * (1 - elapsedFraction) + return UsageLimit(utilization: utilization, resetAt: Date().addingTimeInterval(remaining)) + } + + private func usageData(session: UsageLimit, weekly: UsageLimit) -> UsageData { + UsageData(sessionUsage: session, weeklyUsage: weekly, sonnetUsage: nil, lastUpdated: Date()) + } + + // MARK: - paceRatio + + func test_paceRatio_atSustainablePace_isOne() { + let usageLimit = limit(utilization: 50, elapsedFraction: 0.5, window: sessionWindow) + XCTAssertEqual(usageLimit.paceRatio(windowDuration: sessionWindow) ?? 0, 1.0, accuracy: 0.01) + } + + func test_paceRatio_burningFast_isAboveOne() { + // 50% used at 25% elapsed = 2.0 + let usageLimit = limit(utilization: 50, elapsedFraction: 0.25, window: sessionWindow) + XCTAssertEqual(usageLimit.paceRatio(windowDuration: sessionWindow) ?? 0, 2.0, accuracy: 0.01) + } + + func test_paceRatio_underusing_isBelowOne() { + // 20% used at 50% elapsed = 0.4 + let usageLimit = limit(utilization: 20, elapsedFraction: 0.5, window: weeklyWindow) + XCTAssertEqual(usageLimit.paceRatio(windowDuration: weeklyWindow) ?? 0, 0.4, accuracy: 0.01) + } + + func test_paceRatio_withinGracePeriod_belowUsageFloor_isNil() { + // 1% used in the first 2% of the window: below both the elapsed grace and + // the usage floor, so the ratio stays suppressed as noise. + let usageLimit = limit(utilization: 1, elapsedFraction: 0.02, window: sessionWindow) + XCTAssertNil(usageLimit.paceRatio(windowDuration: sessionWindow)) + } + + func test_paceRatio_withinGracePeriod_aboveUsageFloor_surfaces() { + // A front-loaded burst clears the usage floor, so the ratio surfaces before + // the elapsed grace ends: 10% used at 2% elapsed -> 5.0. + let usageLimit = limit(utilization: 10, elapsedFraction: 0.02, window: sessionWindow) + XCTAssertEqual(usageLimit.paceRatio(windowDuration: sessionWindow) ?? 0, 5.0, accuracy: 0.01) + } + + func test_paceRatio_pastReset_isNil() { + let usageLimit = UsageLimit(utilization: 50, resetAt: Date().addingTimeInterval(-60)) + XCTAssertNil(usageLimit.paceRatio(windowDuration: sessionWindow)) + } + + // MARK: - UsageData.paceSignal (hybrid rule) + + func test_paceSignal_sessionBurningFast_isHotFromSessionWindow() { + let data = usageData( + session: limit(utilization: 50, elapsedFraction: 0.25, window: sessionWindow), + weekly: limit(utilization: 50, elapsedFraction: 0.5, window: weeklyWindow) + ) + + let signal = data.paceSignal(weeklyPaceDays: 7) + XCTAssertEqual(signal?.kind, .hot) + XCTAssertEqual(signal?.windowName, "5-hour") + } + + func test_paceSignal_bothHot_picksHigherRatio() { + let data = usageData( + session: limit(utilization: 60, elapsedFraction: 0.4, window: sessionWindow), // 1.5 + weekly: limit(utilization: 90, elapsedFraction: 0.3, window: weeklyWindow) // 3.0 + ) + + let signal = data.paceSignal(weeklyPaceDays: 7) + XCTAssertEqual(signal?.kind, .hot) + XCTAssertEqual(signal?.windowName, "7-day") + } + + func test_paceSignal_weeklyUnderused_isCold() { + let data = usageData( + session: limit(utilization: 50, elapsedFraction: 0.5, window: sessionWindow), + weekly: limit(utilization: 20, elapsedFraction: 0.5, window: weeklyWindow) // 0.4 + ) + + let signal = data.paceSignal(weeklyPaceDays: 7) + XCTAssertEqual(signal?.kind, .cold) + XCTAssertEqual(signal?.windowName, "7-day") + } + + func test_paceSignal_sessionIdleButWeeklyOnPace_isNil() { + // Session underuse alone must not produce a cold signal + let data = usageData( + session: limit(utilization: 10, elapsedFraction: 0.8, window: sessionWindow), // 0.125 + weekly: limit(utilization: 50, elapsedFraction: 0.5, window: weeklyWindow) // 1.0 + ) + + XCTAssertNil(data.paceSignal(weeklyPaceDays: 7)) + } + + func test_paceSignal_sessionHotWinsOverWeeklyCold() { + // Imminent session lockout beats long-term weekly underuse + let data = usageData( + session: limit(utilization: 50, elapsedFraction: 0.25, window: sessionWindow), // 2.0 + weekly: limit(utilization: 20, elapsedFraction: 0.5, window: weeklyWindow) // 0.4 + ) + + XCTAssertEqual(data.paceSignal(weeklyPaceDays: 7)?.kind, .hot) + } + + func test_paceSignal_onPaceEverywhere_isNil() { + let data = usageData( + session: limit(utilization: 50, elapsedFraction: 0.5, window: sessionWindow), + weekly: limit(utilization: 50, elapsedFraction: 0.5, window: weeklyWindow) + ) + + XCTAssertNil(data.paceSignal(weeklyPaceDays: 7)) + } + + // MARK: - Weekly pace basis (5/6/7 days) + + func test_paceRatio_fiveDayPacing_expectsFasterBurn() { + // 40% used at 2 of 7 days elapsed: on 5-day basis expected is 40% -> ratio 1.0 + let usageLimit = limit(utilization: 40, elapsedFraction: 2.0 / 7.0, window: weeklyWindow) + let fiveDays: TimeInterval = 5 * 24 * 60 * 60 + + XCTAssertEqual( + usageLimit.paceRatio(windowDuration: weeklyWindow, pacingDuration: fiveDays) ?? 0, + 1.0, + accuracy: 0.01 + ) + } + + func test_paceRatio_pastPacingDuration_capsElapsedAtFull() { + // Day 6 of 7 on a 5-day basis: expected usage is 100%, so ratio equals usage fraction + let usageLimit = limit(utilization: 70, elapsedFraction: 6.0 / 7.0, window: weeklyWindow) + let fiveDays: TimeInterval = 5 * 24 * 60 * 60 + + XCTAssertEqual( + usageLimit.paceRatio(windowDuration: weeklyWindow, pacingDuration: fiveDays) ?? 0, + 0.7, + accuracy: 0.01 + ) + } + + // MARK: - Projection + + func test_projectedEndPercent_extrapolatesCurrentRate() { + // 40% used at 50% elapsed -> 80% at window end + let usageLimit = limit(utilization: 40, elapsedFraction: 0.5, window: sessionWindow) + + XCTAssertEqual( + usageLimit.projectedEndPercent(windowDuration: sessionWindow) ?? 0, + 80, + accuracy: 0.5 + ) + } + + func test_projectedEndPercent_withinGracePeriod_aboveUsageFloor_surfaces() { + // 10% used at 2% elapsed extrapolates to 500% - surfaces once the usage + // floor is cleared, without waiting out the elapsed grace. + let usageLimit = limit(utilization: 10, elapsedFraction: 0.02, window: sessionWindow) + XCTAssertEqual(usageLimit.projectedEndPercent(windowDuration: sessionWindow) ?? 0, 500, accuracy: 1) + } + + func test_projectedEndPercent_withinGracePeriod_belowUsageFloor_isNil() { + // Trivial early usage stays suppressed - below both grace and usage floor. + let usageLimit = limit(utilization: 1, elapsedFraction: 0.02, window: sessionWindow) + XCTAssertNil(usageLimit.projectedEndPercent(windowDuration: sessionWindow)) + } + + func test_projectedLimitDate_whenBurningFast_isBeforeReset() { + // 60% used at 50% elapsed -> hits 100% at ~83% of the window, before reset + let usageLimit = limit(utilization: 60, elapsedFraction: 0.5, window: sessionWindow) + + guard let hitDate = usageLimit.projectedLimitDate(windowDuration: sessionWindow) else { + return XCTFail("Expected a projected limit date") + } + XCTAssertLessThan(hitDate, usageLimit.resetAt) + + // Hit at elapsed * 100/60 = 0.833 of the window + let windowStart = usageLimit.resetAt.addingTimeInterval(-sessionWindow) + let hitFraction = hitDate.timeIntervalSince(windowStart) / sessionWindow + XCTAssertEqual(hitFraction, 5.0 / 6.0, accuracy: 0.01) + } + + func test_projectedLimitDate_whenOnSustainablePace_isNil() { + let usageLimit = limit(utilization: 50, elapsedFraction: 0.5, window: sessionWindow) + XCTAssertNil(usageLimit.projectedLimitDate(windowDuration: sessionWindow)) + } + + func test_projectedLimitDate_whenAlreadyExceeded_isNil() { + let usageLimit = limit(utilization: 105, elapsedFraction: 0.5, window: sessionWindow) + XCTAssertNil(usageLimit.projectedLimitDate(windowDuration: sessionWindow)) + } + + func test_projectedLimitDate_frontLoadedBurstWithinGracePeriod_stillWarns() { + // 60% burned in the first ~2% of the window - below the pace grace period, + // but a lockout is unambiguous, so the projection must still fire. + let usageLimit = limit(utilization: 60, elapsedFraction: 0.02, window: sessionWindow) + // The migration surfaces the ratio too once usage clears the floor: 60% at 2% elapsed -> 30.0. + XCTAssertEqual(usageLimit.paceRatio(windowDuration: sessionWindow) ?? 0, 30.0, accuracy: 0.01, + "usage floor surfaces the ratio inside the elapsed grace") + + guard let hitDate = usageLimit.projectedLimitDate(windowDuration: sessionWindow) else { + return XCTFail("Expected an early limit projection for a heavy front-loaded burn") + } + XCTAssertLessThan(hitDate, usageLimit.resetAt) + } + + func test_projectedLimitDate_trivialEarlyUsage_isNil() { + // 1% used moments after reset is noise, not a lockout - below the usage floor. + let usageLimit = limit(utilization: 1, elapsedFraction: 0.01, window: sessionWindow) + XCTAssertNil(usageLimit.projectedLimitDate(windowDuration: sessionWindow)) + } + + func test_projection_respectsPacingBasis_noContradiction() { + // 60% used at 4/7 of the week, paced over 5 days: ratio is under-pace (< 0.8), + // so the projection must agree - end below 100%, no limit-hit warning. + let fiveDays = Constants.Pacing.weeklyPacingDuration(days: 5) + let usageLimit = limit(utilization: 60, elapsedFraction: 4.0 / 7.0, window: weeklyWindow) + + let ratio = usageLimit.paceRatio(windowDuration: weeklyWindow, pacingDuration: fiveDays) ?? 0 + XCTAssertLessThan(ratio, Constants.Pacing.underuseThreshold) + + XCTAssertNil(usageLimit.projectedLimitDate(windowDuration: weeklyWindow, pacingDuration: fiveDays)) + let end = usageLimit.projectedEndPercent(windowDuration: weeklyWindow, pacingDuration: fiveDays) ?? 0 + XCTAssertLessThan(end, 100) + } + + // MARK: - Tooltip + + func test_tooltip_cold_showsUsedVsExpectedAndPaceBasis() { + let signal = PaceSignal( + kind: .cold, ratio: 0.29, windowName: "7-day", + usedPercent: 11.4, expectedPercent: 40, paceDays: 5 + ) + + XCTAssertEqual( + signal.tooltip, + "Used 11% vs 40% expected by now - 0.3× pace, quota may go unused (5/7-day window)" + ) + } + + func test_tooltip_hot_omitsPaceBasisForSessionWindow() { + let signal = PaceSignal( + kind: .hot, ratio: 1.8, windowName: "5-hour", + usedPercent: 72, expectedPercent: 40, paceDays: nil + ) + + XCTAssertEqual( + signal.tooltip, + "Used 72% vs 40% expected by now - burning 1.8× sustainable pace (5-hour window)" + ) + } + + func test_paceSignal_zeroUtilization_hasZeroRatioAndFiniteExpected() { + let data = usageData( + session: limit(utilization: 50, elapsedFraction: 0.5, window: sessionWindow), + weekly: limit(utilization: 0, elapsedFraction: 0.5, window: weeklyWindow) + ) + + let signal = data.paceSignal(weeklyPaceDays: 7) + XCTAssertEqual(signal?.kind, .cold) + XCTAssertEqual(signal?.ratio ?? -1, 0, accuracy: 0.001) + XCTAssertEqual(signal?.expectedPercent ?? 0, 50, accuracy: 1) + } + + func test_paceSignal_hotOnSevenDayBasis_notHotOnFiveDayBasis() { + // Weekly 40% used at 2 of 7 days elapsed: 7-day ratio 1.4 (hot), 5-day ratio 1.0 (on pace) + let data = usageData( + session: limit(utilization: 50, elapsedFraction: 0.5, window: sessionWindow), + weekly: limit(utilization: 40, elapsedFraction: 2.0 / 7.0, window: weeklyWindow) + ) + + XCTAssertEqual(data.paceSignal(weeklyPaceDays: 7)?.kind, .hot) + XCTAssertNil(data.paceSignal(weeklyPaceDays: 5)) + } + + // MARK: - fallbackPaceRatio (menu bar, no off-pace signal) + + func test_fallbackPaceRatio_leadsWithWorstWindowNotSession() { + // No off-pace signal: the session idles at ~0.1x while the weekly window + // is on pace at 1.0x. The menu bar must lead with the worst (highest) + // ratio, not default to the idle session. + let data = usageData( + session: limit(utilization: 7, elapsedFraction: 0.64, window: sessionWindow), // ~0.11 + weekly: limit(utilization: 13, elapsedFraction: 0.13, window: weeklyWindow) // 1.0 + ) + + XCTAssertNil(data.paceSignal(weeklyPaceDays: 7), "neither window is off pace") + XCTAssertEqual(data.fallbackPaceRatio(weeklyPaceDays: 7) ?? 0, 1.0, accuracy: 0.02) + } + + func test_fallbackPaceRatio_isNilWhenBothWindowsSuppressed() { + // Both barely started and below the usage floor: no ratio to lead with. + let data = usageData( + session: limit(utilization: 1, elapsedFraction: 0.02, window: sessionWindow), + weekly: limit(utilization: 1, elapsedFraction: 0.02, window: weeklyWindow) + ) + + XCTAssertNil(data.fallbackPaceRatio(weeklyPaceDays: 7)) + } +} diff --git a/ClaudeMeterTests/UsageLimitRiskTests.swift b/ClaudeMeterTests/UsageLimitRiskTests.swift index a491bc7..d89cb37 100644 --- a/ClaudeMeterTests/UsageLimitRiskTests.swift +++ b/ClaudeMeterTests/UsageLimitRiskTests.swift @@ -13,7 +13,7 @@ final class UsageLimitRiskTests: XCTestCase { private let sessionWindow: TimeInterval = 5 * 60 * 60 // 5 hours func test_isAtRisk_whenUsingFasterThanSustainable_returnsTrue() { - // 25% of time elapsed, 50% usage = ratio of 2.0 (> 1.2 threshold) + // 25% of time elapsed, 50% usage = ratio of 2.0 (> 1.0 threshold) let resetAt = Date().addingTimeInterval(3.75 * 60 * 60) // 3.75 hours remaining let usageLimit = UsageLimit(utilization: 50.0, resetAt: resetAt) @@ -21,7 +21,7 @@ final class UsageLimitRiskTests: XCTestCase { } func test_isAtRisk_whenUsingAtSustainablePace_returnsFalse() { - // 50% of time elapsed, 50% usage = ratio of 1.0 (< 1.2 threshold) + // 50% of time elapsed, 50% usage = ratio of 1.0 (at the 1.0 threshold, not above) let resetAt = Date().addingTimeInterval(2.5 * 60 * 60) // 2.5 hours remaining let usageLimit = UsageLimit(utilization: 50.0, resetAt: resetAt) @@ -29,9 +29,9 @@ final class UsageLimitRiskTests: XCTestCase { } func test_isAtRisk_whenSlightlyAboveThreshold_returnsTrue() { - // 50% of time elapsed, 65% usage = ratio of 1.3 (> 1.2 threshold) + // 50% of time elapsed, 55% usage = ratio of 1.1 (just above the 1.0 threshold) let resetAt = Date().addingTimeInterval(2.5 * 60 * 60) // 2.5 hours remaining - let usageLimit = UsageLimit(utilization: 65.0, resetAt: resetAt) + let usageLimit = UsageLimit(utilization: 55.0, resetAt: resetAt) XCTAssertTrue(usageLimit.isAtRisk(windowDuration: sessionWindow)) }