Files
steipete--codexbar/Sources/CodexBarCore/UsagePace.swift
T
2026-07-13 12:22:33 +08:00

264 lines
9.4 KiB
Swift

import Foundation
public struct UsagePace: Sendable {
public enum Stage: Sendable {
case onTrack
case slightlyAhead
case ahead
case farAhead
case slightlyBehind
case behind
case farBehind
}
public let stage: Stage
public let deltaPercent: Double
public let expectedUsedPercent: Double
public let actualUsedPercent: Double
public let etaSeconds: TimeInterval?
public let willLastToReset: Bool
public let runOutProbability: Double?
public let speedMultiplierToReset: Double?
public init(
stage: Stage,
deltaPercent: Double,
expectedUsedPercent: Double,
actualUsedPercent: Double,
etaSeconds: TimeInterval?,
willLastToReset: Bool,
runOutProbability: Double? = nil,
speedMultiplierToReset: Double? = nil)
{
self.stage = stage
self.deltaPercent = deltaPercent
self.expectedUsedPercent = expectedUsedPercent
self.actualUsedPercent = actualUsedPercent
self.etaSeconds = etaSeconds
self.willLastToReset = willLastToReset
self.runOutProbability = runOutProbability
self.speedMultiplierToReset = speedMultiplierToReset
}
public static func weekly(
window: RateWindow,
now: Date = .init(),
defaultWindowMinutes: Int = 10080,
workDays: Int? = nil,
calendar: Calendar = .current) -> UsagePace?
{
guard let resetsAt = window.resetsAt else { return nil }
let minutes = window.windowMinutes ?? defaultWindowMinutes
guard minutes > 0 else { return nil }
let duration = TimeInterval(minutes) * 60
let timeUntilReset = resetsAt.timeIntervalSince(now)
guard timeUntilReset > 0 else { return nil }
guard timeUntilReset <= duration else { return nil }
let elapsed = (duration - timeUntilReset).clamped(to: 0...duration)
let workdayProgress: WorkdayProgress? = if let workDays, workDays >= 2, workDays < 7,
minutes == 10080
{
Self.workdayProgress(
now: now,
duration: duration,
resetsAt: resetsAt,
workDays: workDays,
calendar: calendar)
} else {
nil
}
let expected = workdayProgress?.expectedUsedPercent
?? ((elapsed / duration) * 100).clamped(to: 0...100)
let actual = window.usedPercent.clamped(to: 0...100)
if elapsed == 0, actual > 0 {
return nil
}
let delta = actual - expected
let stage = Self.stage(for: delta)
var etaSeconds: TimeInterval?
var willLastToReset = false
let paceElapsed = workdayProgress?.elapsedSeconds ?? elapsed
let effectiveTimeUntilReset = workdayProgress?.remainingSeconds ?? timeUntilReset
let projectedRemainingUsage = paceElapsed > 0
? actual * effectiveTimeUntilReset / paceElapsed
: 0
let speedMultiplierToReset = Self.safeSpeedMultiplier(
remainingCapacity: 100 - actual,
projectedRemainingUsage: projectedRemainingUsage)
if actual >= 100 {
etaSeconds = 0
} else if paceElapsed > 0, actual > 0 {
let rate = actual / paceElapsed
if rate > 0 {
let remaining = 100 - actual
let candidate = remaining / rate
if candidate >= effectiveTimeUntilReset {
willLastToReset = true
} else if let workDays = workdayProgress?.workDays {
etaSeconds = Self.wallClockInterval(
from: now,
to: resetsAt,
consumingWorkSeconds: candidate,
workDays: workDays,
calendar: calendar)
} else {
etaSeconds = candidate
}
}
} else if paceElapsed > 0, actual == 0 {
willLastToReset = true
}
return UsagePace(
stage: stage,
deltaPercent: delta,
expectedUsedPercent: expected,
actualUsedPercent: actual,
etaSeconds: etaSeconds,
willLastToReset: willLastToReset,
runOutProbability: nil,
speedMultiplierToReset: speedMultiplierToReset)
}
public static func historical(
expectedUsedPercent: Double,
actualUsedPercent: Double,
etaSeconds: TimeInterval?,
willLastToReset: Bool,
runOutProbability: Double?,
projectedRemainingUsage: Double? = nil) -> UsagePace
{
let expected = expectedUsedPercent.clamped(to: 0...100)
let actual = actualUsedPercent.clamped(to: 0...100)
let delta = actual - expected
return UsagePace(
stage: Self.stage(for: delta),
deltaPercent: delta,
expectedUsedPercent: expected,
actualUsedPercent: actual,
etaSeconds: etaSeconds,
willLastToReset: willLastToReset,
runOutProbability: runOutProbability,
speedMultiplierToReset: projectedRemainingUsage.flatMap {
Self.safeSpeedMultiplier(
remainingCapacity: 100 - actual,
projectedRemainingUsage: $0)
})
}
private static func safeSpeedMultiplier(
remainingCapacity: Double,
projectedRemainingUsage: Double) -> Double?
{
guard remainingCapacity > 0, projectedRemainingUsage > 0 else { return nil }
let multiplier = remainingCapacity / projectedRemainingUsage
return multiplier.isFinite ? multiplier : nil
}
private struct WorkdayProgress {
let workDays: Int
let totalSeconds: TimeInterval
let elapsedSeconds: TimeInterval
let remainingSeconds: TimeInterval
var expectedUsedPercent: Double {
((self.elapsedSeconds / self.totalSeconds) * 100).clamped(to: 0...100)
}
}
/// Splits the weekly window at local day boundaries so reset offsets do not shift weekday classification.
private static func workdayProgress(
now: Date,
duration: TimeInterval,
resetsAt: Date,
workDays: Int,
calendar: Calendar) -> WorkdayProgress?
{
let windowStart = resetsAt.addingTimeInterval(-duration)
var totalWorkSeconds: TimeInterval = 0
var elapsedWorkSeconds: TimeInterval = 0
var remainingWorkSeconds: TimeInterval = 0
var cursor = windowStart
while cursor < resetsAt {
guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar),
startOfNextDay > cursor
else {
return nil
}
let sliceEnd = min(startOfNextDay, resetsAt)
if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) {
let sliceDuration = sliceEnd.timeIntervalSince(cursor)
totalWorkSeconds += sliceDuration
if now > cursor {
elapsedWorkSeconds += min(now, sliceEnd).timeIntervalSince(cursor)
}
if now < sliceEnd {
remainingWorkSeconds += sliceEnd.timeIntervalSince(max(now, cursor))
}
}
cursor = sliceEnd
}
guard totalWorkSeconds > 0 else { return nil }
return WorkdayProgress(
workDays: workDays,
totalSeconds: totalWorkSeconds,
elapsedSeconds: elapsedWorkSeconds,
remainingSeconds: remainingWorkSeconds)
}
private static func wallClockInterval(
from now: Date,
to resetsAt: Date,
consumingWorkSeconds requiredWorkSeconds: TimeInterval,
workDays: Int,
calendar: Calendar) -> TimeInterval?
{
guard requiredWorkSeconds > 0 else { return 0 }
var remaining = requiredWorkSeconds
var cursor = now
while cursor < resetsAt {
guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar),
startOfNextDay > cursor
else {
return nil
}
let sliceEnd = min(startOfNextDay, resetsAt)
if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) {
let available = sliceEnd.timeIntervalSince(cursor)
if remaining <= available {
return cursor.addingTimeInterval(remaining).timeIntervalSince(now)
}
remaining -= available
}
cursor = sliceEnd
}
return nil
}
private static func nextDayBoundary(after date: Date, calendar: Calendar) -> Date? {
calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date))
}
private static func isWorkday(_ date: Date, calendar: Calendar, workDays: Int) -> Bool {
let weekday = calendar.component(.weekday, from: date)
let isoWeekday = weekday == 1 ? 7 : weekday - 1
return isoWeekday <= workDays
}
private static func stage(for delta: Double) -> Stage {
let absDelta = abs(delta)
if absDelta <= 2 { return .onTrack }
if absDelta <= 6 { return delta >= 0 ? .slightlyAhead : .slightlyBehind }
if absDelta <= 12 { return delta >= 0 ? .ahead : .behind }
return delta >= 0 ? .farAhead : .farBehind
}
}