From f53f2c6b556be830b409ef500b575f37db2e1723 Mon Sep 17 00:00:00 2001 From: Dinanath Dash Date: Wed, 13 May 2026 16:20:37 +0530 Subject: [PATCH 1/3] feat: Refactor time remaining calculation and integrate TimeRemainingEstimator --- Stasis/Models/TimeRemainingEstimator.swift | 104 +++++++++++++++++++++ Stasis/ViewModels/MenuViewModel.swift | 41 +++++--- 2 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 Stasis/Models/TimeRemainingEstimator.swift diff --git a/Stasis/Models/TimeRemainingEstimator.swift b/Stasis/Models/TimeRemainingEstimator.swift new file mode 100644 index 0000000..aa3d82e --- /dev/null +++ b/Stasis/Models/TimeRemainingEstimator.swift @@ -0,0 +1,104 @@ +import Foundation + +@MainActor +class TimeRemainingEstimator { + private var trendSample: (date: Date, percentage: Int, isCharging: Bool)? + + func formatTimeRemaining( + reportedMinutes: Int, + powerSource: PowerSource, + isCharging: Bool, + adapterConnected: Bool, + batteryPercentage: Int, + chargingTargetPercentage: Int + ) -> String { + if adapterConnected && !isCharging { + return "N/A" + } + + let adjustedReportedMinutes = adjustedReportedMinutesToTarget( + reportedMinutes: reportedMinutes, + isCharging: isCharging, + batteryPercentage: batteryPercentage, + targetPercentage: chargingTargetPercentage + ) + + let fallbackMinutes = estimateMinutesFromTrend( + batteryPercentage: batteryPercentage, + isCharging: isCharging, + adapterConnected: adapterConnected, + targetPercentage: chargingTargetPercentage + ) + + let effectiveMinutes = adjustedReportedMinutes >= 0 ? adjustedReportedMinutes : fallbackMinutes + guard let effectiveMinutes, effectiveMinutes >= 0 else { + return "Calculating..." + } + + let hours = effectiveMinutes / 60 + let mins = effectiveMinutes % 60 + return String(format: "%02d:%02d", hours, mins) + } + + private func adjustedReportedMinutesToTarget( + reportedMinutes: Int, + isCharging: Bool, + batteryPercentage: Int, + targetPercentage: Int + ) -> Int { + guard reportedMinutes >= 0 else { return -1 } + guard isCharging else { return reportedMinutes } + + if batteryPercentage >= targetPercentage { + return 0 + } + + if targetPercentage >= 100 || batteryPercentage >= 100 { + return reportedMinutes + } + + let remainingToTarget = max(0, targetPercentage - batteryPercentage) + let remainingToFull = max(1, 100 - batteryPercentage) + let scaled = Double(reportedMinutes) * Double(remainingToTarget) / Double(remainingToFull) + return Int(ceil(scaled)) + } + + private func estimateMinutesFromTrend( + batteryPercentage: Int, + isCharging: Bool, + adapterConnected: Bool, + targetPercentage: Int + ) -> Int? { + let now = Date() + defer { + trendSample = (date: now, percentage: batteryPercentage, isCharging: isCharging) + } + + guard !(adapterConnected && !isCharging) else { return nil } + guard let previous = trendSample else { return nil } + guard previous.isCharging == isCharging else { return nil } + + let elapsedMinutes = now.timeIntervalSince(previous.date) / 60 + guard elapsedMinutes >= 0.5 else { return nil } + + let deltaPercent = batteryPercentage - previous.percentage + guard deltaPercent != 0 else { return nil } + + let percentPerMinute = abs(Double(deltaPercent) / elapsedMinutes) + guard percentPerMinute > 0 else { return nil } + + let remainingPercent: Int = { + if isCharging { + return max(0, targetPercentage - batteryPercentage) + } + return max(0, batteryPercentage) + }() + + if remainingPercent == 0 { + return 0 + } + + let minutes = Double(remainingPercent) / percentPerMinute + return Int(ceil(minutes)) + } +} diff --git a/Stasis/ViewModels/MenuViewModel.swift b/Stasis/ViewModels/MenuViewModel.swift index 4b2b56a..cc1d7af 100644 --- a/Stasis/ViewModels/MenuViewModel.swift +++ b/Stasis/ViewModels/MenuViewModel.swift @@ -37,6 +37,7 @@ class MenuViewModel { private var metricsObservation: Task? private var settingsObservation: Task? private var uptimeTask: Task? + private let timeEstimator = TimeRemainingEstimator() init(batteryService: BatteryService, chargeManager: ChargeManager) { self.batteryService = batteryService @@ -107,8 +108,7 @@ class MenuViewModel { powerSourceText = "Battery & Power Adapter" } - let formatted = formatTimeRemaining(minutes: metrics.timeRemaining) - timeRemainingText = formatted.isEmpty ? "Calculating..." : formatted + timeRemainingText = formatTimeRemaining(minutes: metrics.timeRemaining, powerSource: derivedPowerSource, isCharging: metrics.isCharging) updateUptimeText() @@ -166,10 +166,18 @@ class MenuViewModel { return } - let uptime = Date().timeIntervalSince(bootTimestamp) - let hours = Int(uptime) / 3600 - let minutes = (Int(uptime) % 3600) / 60 - uptimeText = String(format: "%02d:%02d", hours, minutes) + let uptime = max(0, Int(Date().timeIntervalSince(bootTimestamp))) + let days = uptime / 86_400 + let hours = (uptime % 86_400) / 3_600 + let minutes = (uptime % 3_600) / 60 + + if days > 0 { + uptimeText = "\(days)D \(hours)H \(minutes)M" + } else if hours > 0 { + uptimeText = "\(hours)H \(minutes)M" + } else { + uptimeText = "\(minutes)M" + } } private func startUptimeTimer() { @@ -203,13 +211,22 @@ class MenuViewModel { NSApplication.shared.terminate(nil) } - private func formatTimeRemaining(minutes: Int) -> String { - if minutes < 0 { - return "" + private func formatTimeRemaining(minutes: Int, powerSource: PowerSource, isCharging: Bool) -> String { + return timeEstimator.formatTimeRemaining( + reportedMinutes: minutes, + powerSource: powerSource, + isCharging: isCharging, + adapterConnected: adapterConnected, + batteryPercentage: displayPercentage, + chargingTargetPercentage: chargingTargetPercentage + ) + } + + private var chargingTargetPercentage: Int { + if Defaults[.manageCharging] && !chargeLimitOverrideActive { + return Defaults[.chargeLimit] } - let hours = minutes / 60 - let mins = minutes % 60 - return String(format: "%02d:%02d", hours, mins) + return 100 } deinit { From c301a527691f2b8e870f92262228d2879eb28f82 Mon Sep 17 00:00:00 2001 From: Dinanath Dash Date: Sat, 16 May 2026 15:43:24 +0530 Subject: [PATCH 2/3] refactor: resolve reviewer feedback on time estimator --- Stasis/Models/TimeRemainingEstimator.swift | 67 ++++++---------------- Stasis/ViewModels/MenuViewModel.swift | 36 ++++++------ 2 files changed, 37 insertions(+), 66 deletions(-) diff --git a/Stasis/Models/TimeRemainingEstimator.swift b/Stasis/Models/TimeRemainingEstimator.swift index aa3d82e..565488a 100644 --- a/Stasis/Models/TimeRemainingEstimator.swift +++ b/Stasis/Models/TimeRemainingEstimator.swift @@ -1,69 +1,38 @@ import Foundation -@MainActor -class TimeRemainingEstimator { - private var trendSample: (date: Date, percentage: Int, isCharging: Bool)? +struct TimeRemainingEstimator { + private struct TrendSample { + let date: Date + let percentage: Int + let isCharging: Bool + } + + private var trendSample: TrendSample? - func formatTimeRemaining( + mutating func estimateTimeRemaining( reportedMinutes: Int, - powerSource: PowerSource, isCharging: Bool, adapterConnected: Bool, batteryPercentage: Int, chargingTargetPercentage: Int - ) -> String { - if adapterConnected && !isCharging { - return "N/A" + ) -> Int? { + if batteryPercentage >= chargingTargetPercentage && isCharging { + return 0 } - let adjustedReportedMinutes = adjustedReportedMinutesToTarget( - reportedMinutes: reportedMinutes, - isCharging: isCharging, - batteryPercentage: batteryPercentage, - targetPercentage: chargingTargetPercentage - ) + if reportedMinutes >= 0 { + return reportedMinutes + } - let fallbackMinutes = estimateMinutesFromTrend( + return estimateMinutesFromTrend( batteryPercentage: batteryPercentage, isCharging: isCharging, adapterConnected: adapterConnected, targetPercentage: chargingTargetPercentage ) - - let effectiveMinutes = adjustedReportedMinutes >= 0 ? adjustedReportedMinutes : fallbackMinutes - guard let effectiveMinutes, effectiveMinutes >= 0 else { - return "Calculating..." - } - - let hours = effectiveMinutes / 60 - let mins = effectiveMinutes % 60 - return String(format: "%02d:%02d", hours, mins) - } - - private func adjustedReportedMinutesToTarget( - reportedMinutes: Int, - isCharging: Bool, - batteryPercentage: Int, - targetPercentage: Int - ) -> Int { - guard reportedMinutes >= 0 else { return -1 } - guard isCharging else { return reportedMinutes } - - if batteryPercentage >= targetPercentage { - return 0 - } - - if targetPercentage >= 100 || batteryPercentage >= 100 { - return reportedMinutes - } - - let remainingToTarget = max(0, targetPercentage - batteryPercentage) - let remainingToFull = max(1, 100 - batteryPercentage) - let scaled = Double(reportedMinutes) * Double(remainingToTarget) / Double(remainingToFull) - return Int(ceil(scaled)) } - private func estimateMinutesFromTrend( + private mutating func estimateMinutesFromTrend( batteryPercentage: Int, isCharging: Bool, adapterConnected: Bool, @@ -71,7 +40,7 @@ class TimeRemainingEstimator { ) -> Int? { let now = Date() defer { - trendSample = (date: now, percentage: batteryPercentage, isCharging: isCharging) + trendSample = TrendSample(date: now, percentage: batteryPercentage, isCharging: isCharging) } guard !(adapterConnected && !isCharging) else { return nil } diff --git a/Stasis/ViewModels/MenuViewModel.swift b/Stasis/ViewModels/MenuViewModel.swift index cc1d7af..3ce9807 100644 --- a/Stasis/ViewModels/MenuViewModel.swift +++ b/Stasis/ViewModels/MenuViewModel.swift @@ -37,7 +37,7 @@ class MenuViewModel { private var metricsObservation: Task? private var settingsObservation: Task? private var uptimeTask: Task? - private let timeEstimator = TimeRemainingEstimator() + private var timeEstimator = TimeRemainingEstimator() init(batteryService: BatteryService, chargeManager: ChargeManager) { self.batteryService = batteryService @@ -108,8 +108,6 @@ class MenuViewModel { powerSourceText = "Battery & Power Adapter" } - timeRemainingText = formatTimeRemaining(minutes: metrics.timeRemaining, powerSource: derivedPowerSource, isCharging: metrics.isCharging) - updateUptimeText() if derivedPowerSource == .acAdapter { @@ -144,6 +142,8 @@ class MenuViewModel { isCharging = metrics.isCharging adapterConnected = adapter.adapterConnected + timeRemainingText = formatTimeRemaining(minutes: metrics.timeRemaining, isCharging: metrics.isCharging) + cycleCountText = "\(metrics.cycleCount)" batteryHealthText = "\(metrics.batteryHealth)%" } @@ -167,17 +167,10 @@ class MenuViewModel { } let uptime = max(0, Int(Date().timeIntervalSince(bootTimestamp))) - let days = uptime / 86_400 - let hours = (uptime % 86_400) / 3_600 - let minutes = (uptime % 3_600) / 60 - - if days > 0 { - uptimeText = "\(days)D \(hours)H \(minutes)M" - } else if hours > 0 { - uptimeText = "\(hours)H \(minutes)M" - } else { - uptimeText = "\(minutes)M" - } + let duration = Duration.seconds(uptime) + uptimeText = duration.formatted( + .units(allowed: [.days, .hours, .minutes], width: .abbreviated) + ) } private func startUptimeTimer() { @@ -211,15 +204,24 @@ class MenuViewModel { NSApplication.shared.terminate(nil) } - private func formatTimeRemaining(minutes: Int, powerSource: PowerSource, isCharging: Bool) -> String { - return timeEstimator.formatTimeRemaining( + private func formatTimeRemaining(minutes: Int, isCharging: Bool) -> String { + if adapterConnected && !isCharging { + return "N/A" + } + + let estimatedMinutes = timeEstimator.estimateTimeRemaining( reportedMinutes: minutes, - powerSource: powerSource, isCharging: isCharging, adapterConnected: adapterConnected, batteryPercentage: displayPercentage, chargingTargetPercentage: chargingTargetPercentage ) + guard let validMinutes = estimatedMinutes, validMinutes >= 0 else { + return "Calculating..." + } + let hours = validMinutes / 60 + let mins = validMinutes % 60 + return String(format: "%02d:%02d", hours, mins) } private var chargingTargetPercentage: Int { From 57a5f2a4a98fd5d1ccd884524ca79fc4a0b4074b Mon Sep 17 00:00:00 2001 From: Dinanath Dash Date: Mon, 18 May 2026 19:59:43 +0530 Subject: [PATCH 3/3] refactor: rely on system-provided time estimates --- Stasis/Models/TimeRemainingEstimator.swift | 61 ++-------------------- Stasis/ViewModels/MenuViewModel.swift | 40 ++++++++++---- 2 files changed, 34 insertions(+), 67 deletions(-) diff --git a/Stasis/Models/TimeRemainingEstimator.swift b/Stasis/Models/TimeRemainingEstimator.swift index 565488a..16caae1 100644 --- a/Stasis/Models/TimeRemainingEstimator.swift +++ b/Stasis/Models/TimeRemainingEstimator.swift @@ -1,18 +1,9 @@ import Foundation struct TimeRemainingEstimator { - private struct TrendSample { - let date: Date - let percentage: Int - let isCharging: Bool - } - - private var trendSample: TrendSample? - - mutating func estimateTimeRemaining( + func estimateTimeRemaining( reportedMinutes: Int, isCharging: Bool, - adapterConnected: Bool, batteryPercentage: Int, chargingTargetPercentage: Int ) -> Int? { @@ -20,54 +11,10 @@ struct TimeRemainingEstimator { return 0 } - if reportedMinutes >= 0 { - return reportedMinutes - } - - return estimateMinutesFromTrend( - batteryPercentage: batteryPercentage, - isCharging: isCharging, - adapterConnected: adapterConnected, - targetPercentage: chargingTargetPercentage - ) - } - - private mutating func estimateMinutesFromTrend( - batteryPercentage: Int, - isCharging: Bool, - adapterConnected: Bool, - targetPercentage: Int - ) -> Int? { - let now = Date() - defer { - trendSample = TrendSample(date: now, percentage: batteryPercentage, isCharging: isCharging) - } - - guard !(adapterConnected && !isCharging) else { return nil } - guard let previous = trendSample else { return nil } - guard previous.isCharging == isCharging else { return nil } - - let elapsedMinutes = now.timeIntervalSince(previous.date) / 60 - guard elapsedMinutes >= 0.5 else { return nil } - - let deltaPercent = batteryPercentage - previous.percentage - guard deltaPercent != 0 else { return nil } - - let percentPerMinute = abs(Double(deltaPercent) / elapsedMinutes) - guard percentPerMinute > 0 else { return nil } - - let remainingPercent: Int = { - if isCharging { - return max(0, targetPercentage - batteryPercentage) - } - return max(0, batteryPercentage) - }() - - if remainingPercent == 0 { - return 0 + guard reportedMinutes >= 0 else { + return nil } - let minutes = Double(remainingPercent) / percentPerMinute - return Int(ceil(minutes)) + return reportedMinutes } } diff --git a/Stasis/ViewModels/MenuViewModel.swift b/Stasis/ViewModels/MenuViewModel.swift index 3ce9807..6405f2b 100644 --- a/Stasis/ViewModels/MenuViewModel.swift +++ b/Stasis/ViewModels/MenuViewModel.swift @@ -29,7 +29,9 @@ class MenuViewModel { var powerSource: PowerSource = .battery var isCharging: Bool = false - var chargeLimitOverrideActive: Bool { chargeManager.chargeLimitOverrideActive } + var chargeLimitOverrideActive: Bool { + chargeManager.chargeLimitOverrideActive + } var forceDischargeActive: Bool { chargeManager.forceDischargeActive } var manageChargingEnabled: Bool { Defaults[.manageCharging] } var adapterConnected: Bool = false @@ -37,7 +39,7 @@ class MenuViewModel { private var metricsObservation: Task? private var settingsObservation: Task? private var uptimeTask: Task? - private var timeEstimator = TimeRemainingEstimator() + private let timeEstimator = TimeRemainingEstimator() init(batteryService: BatteryService, chargeManager: ChargeManager) { self.batteryService = batteryService @@ -71,7 +73,10 @@ class MenuViewModel { private func startObservingSettings() { settingsObservation = Task { [weak self] in - for await _ in Defaults.updates([.useHardwarePercentage], initial: false) { + for await _ in Defaults.updates( + [.useHardwarePercentage], + initial: false + ) { guard let self else { return } self.updateFormattedValues( from: self.batteryService.metrics, @@ -89,7 +94,10 @@ class MenuViewModel { chargeManager.toggleForceDischarge() } - private func updateFormattedValues(from metrics: BatteryMetrics, adapter: AdapterMetrics) { + private func updateFormattedValues( + from metrics: BatteryMetrics, + adapter: AdapterMetrics + ) { let useHardware = Defaults[.useHardwarePercentage] let percentage = useHardware @@ -97,7 +105,10 @@ class MenuViewModel { displayPercentage = percentage batteryPercentageText = "\(percentage)%" - let derivedPowerSource = derivePowerSource(battery: metrics, adapter: adapter) + let derivedPowerSource = derivePowerSource( + battery: metrics, + adapter: adapter + ) switch derivedPowerSource { case .battery: @@ -126,8 +137,12 @@ class MenuViewModel { batteryTemperatureText = "\(metrics.batteryTemperature.formatted(.number.precision(.fractionLength(1))))°C" - let voltageFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) - let currentFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) + let voltageFormat = FloatingPointFormatStyle.number.precision( + .fractionLength(2) + ) + let currentFormat = FloatingPointFormatStyle.number.precision( + .fractionLength(2) + ) externalInputText = "\(adapter.adapterVoltage.formatted(voltageFormat))V @ \(adapter.adapterCurrent.formatted(currentFormat))A" @@ -142,13 +157,19 @@ class MenuViewModel { isCharging = metrics.isCharging adapterConnected = adapter.adapterConnected - timeRemainingText = formatTimeRemaining(minutes: metrics.timeRemaining, isCharging: metrics.isCharging) + timeRemainingText = formatTimeRemaining( + minutes: metrics.timeRemaining, + isCharging: metrics.isCharging + ) cycleCountText = "\(metrics.cycleCount)" batteryHealthText = "\(metrics.batteryHealth)%" } - private func derivePowerSource(battery: BatteryMetrics, adapter: AdapterMetrics) -> PowerSource { + private func derivePowerSource( + battery: BatteryMetrics, + adapter: AdapterMetrics + ) -> PowerSource { guard adapter.adapterConnected else { return .battery } if adapter.adapterPower == 0 { @@ -212,7 +233,6 @@ class MenuViewModel { let estimatedMinutes = timeEstimator.estimateTimeRemaining( reportedMinutes: minutes, isCharging: isCharging, - adapterConnected: adapterConnected, batteryPercentage: displayPercentage, chargingTargetPercentage: chargingTargetPercentage )