Skip to content

Refactor time remaining calculation and integrate estimator - #18

Closed
DinanathDash wants to merge 4 commits into
srimanachanta:mainfrom
DinanathDash:feat/fix-battery-uptime-standalone
Closed

Refactor time remaining calculation and integrate estimator#18
DinanathDash wants to merge 4 commits into
srimanachanta:mainfrom
DinanathDash:feat/fix-battery-uptime-standalone

Conversation

@DinanathDash

Copy link
Copy Markdown
Contributor

This pull request introduces a new TimeRemainingEstimator class to improve the estimation and formatting of battery time remaining, and refactors how this information is displayed in the menu. It also enhances the display of system uptime to show days, hours, and minutes for better readability.

Battery time estimation improvements:

  • Added a new TimeRemainingEstimator class (Stasis/Models/TimeRemainingEstimator.swift) that calculates and formats time remaining, taking into account charging status, power source, charging targets, and trends in battery percentage. This provides more accurate and user-friendly time remaining estimates.
  • Updated MenuViewModel to use the new TimeRemainingEstimator for displaying time remaining, passing relevant parameters such as power source, charging status, and charging target. [1] [2] [3]
  • Added logic to determine the current charging target percentage, considering user settings and overrides.

User interface enhancements:

  • Improved the formatting of uptime in MenuViewModel to display days, hours, and minutes, making it clearer for users with long uptimes.
Screenshot 2026-05-13 at 4 51 32 PM

Closes #9

@DinanathDash

Copy link
Copy Markdown
Contributor Author

"Hey! @srimanachanta Just a quick heads-up: I've submitted 6 modular PRs (PRs #17 through #22) to break down a larger set of features and make the review process much easier for you.

Because a few of these PRs touch the same core files (like MenuViewModel.swift and BatteryIndicatorView.swift), merging one might cause merge conflicts in the others. Please review and merge them in whatever order makes the most sense to you!

Once you merge a PR, if GitHub flags any of my remaining open PRs with conflicts, just let me know or leave them be—I will actively monitor the repo and locally rebase my remaining branches against your updated main to resolve the conflicts for you. You won't have to resolve the conflicts yourself!
For the easiest path with the least overlap, my suggested review/merge order is:

  1. Fix Homebrew install command by removing --no-quarantine flag #21 (Fix Homebrew install) - Standalone.
  2. Add app icons for multiple sizes and update Contents.json #19 (App icons) - Standalone.
  3. Refactor time remaining calculation and integrate estimator #18 (Refactor time remaining)
  4. Add battery percentage display option in menu bar icon #17 (Battery percentage in menu bar)
  5. feat: reflect Low Power Mode in the menu bar battery icon #22 (Low Power Mode icon) - builds slightly on the view from Add battery percentage display option in menu bar icon #17.
  6. Add outgoing power visualization to Sankey diagram #20 (Outgoing power Sankey diagram) - heaviest changes.

Thanks for maintaining Stasis! Let me know if you need any adjustments.

srimanachanta

This comment was marked as low quality.

@srimanachanta
srimanachanta self-requested a review May 16, 2026 07:45
Comment thread Stasis/ViewModels/MenuViewModel.swift Outdated

let formatted = formatTimeRemaining(minutes: metrics.timeRemaining)
timeRemainingText = formatted.isEmpty ? "Calculating..." : formatted
timeRemainingText = formatTimeRemaining(minutes: metrics.timeRemaining, powerSource: derivedPowerSource, isCharging: metrics.isCharging)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.adapterConnected isn't assigned to the new value until line 145 of this method, so this call evaluates against the previous tick's adapter state.

Comment thread Stasis/ViewModels/MenuViewModel.swift Outdated
uptimeText = "\(hours)H \(minutes)M"
} else {
uptimeText = "\(minutes)M"
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer Foundation's Duration.UnitsFormatStyle over manual interpolation.

let duration = Duration.seconds(uptime)
uptimeText = duration.formatted(
    .units(allowed: [.days, .hours, .minutes], width: .abbreviated)
)


func formatTimeRemaining(
reportedMinutes: Int,
powerSource: PowerSource,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused, should be dropped.

@@ -0,0 +1,104 @@
import Foundation

@MainActor

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class should not be @MainActor. No UI deps, no async, no shared mutable state outside self. Isolation flow is guaranteed as this is only called by the MenuViewModel regardless.

import Foundation

@MainActor
class TimeRemainingEstimator {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be a struct, not a class. One stored property, no identity, no inheritance. Use mutating func formatTimeRemaining(...).


@MainActor
class TimeRemainingEstimator {
private var trendSample: (date: Date, percentage: Int, isCharging: Bool)?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convert this to a struct instead, more readable and easier to expand later (if needed).
private struct TrendSample { let date: Date; let percentage: Int; let isCharging: Bool }

batteryPercentage: Int,
targetPercentage: Int
) -> Int {
guard reportedMinutes >= 0 else { return -1 }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class should just return Int? aswell. estimateMinutesFromTrend already does so this should too to follow Swift BP.

Comment on lines +56 to +58
if targetPercentage >= 100 || batteryPercentage >= 100 {
return reportedMinutes
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? The batteryPercentage >= 100 half of this OR is unreachable: the earlier batteryPercentage >= targetPercentage guard already returned 0 for that case (since targetPercentage ≤ 100).


let remainingToTarget = max(0, targetPercentage - batteryPercentage)
let remainingToFull = max(1, 100 - batteryPercentage)
let scaled = Double(reportedMinutes) * Double(remainingToTarget) / Double(remainingToFull)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumption is incorrect. Charging a laptop battery is rarely linear, where macOS charges noticeably slower in the top ~20% (CC→CV transition). For a user with charge limit at 80% this will tend to over-estimate time-to-target. Some form of a non-linear model needs to be formed based on a charging history and combined with battery health data to estimate a proper time to target. I would rather just give the macOS value than a crude estimation like this.

@DinanathDash

DinanathDash commented May 16, 2026

Copy link
Copy Markdown
Contributor Author

@srimanachanta I've pushed updates addressing all of your feedback.

  1. Fixed the self.adapterConnected assignment issue in updateFormattedValues().
  2. Converted uptimeText format to use Duration.UnitsFormatStyle.
  3. Dropped unused code in TimeRemainingEstimator.
  4. Converted TimeRemainingEstimator to a struct and stripped @MainActor.
  5. Simplified TimeRemainingEstimator's return to an Int? instead of a fully formatted string, extracting formatting back to MenuViewModel.
  6. Created the nested TrendSample struct in TimeRemainingEstimator as suggested.
  7. Removed the arbitrary non-linear assumption that previously blocked trend calculations.

@srimanachanta

Copy link
Copy Markdown
Owner

This is still a linear model to estimate time. We're going to need a nonlinear model for this feature to be useful.

@DinanathDash

Copy link
Copy Markdown
Contributor Author

This is still a linear model to estimate time. We're going to need a nonlinear model for this feature to be useful.

I did not understand, can you tell me how it will be possible, what does the logic mean?

@srimanachanta

Copy link
Copy Markdown
Owner

This is still a linear model to estimate time. We're going to need a nonlinear model for this feature to be useful.

I did not understand, can you tell me how it will be possible, what does the logic mean?

Currently, you estimate the time remaining as a linear relationship where the time it takes to gain 10 percent was the same time it took to gain the last 10 percent. For some parts of the charging model, this is accurate (say 30 -> 40 and 40 -> 50), but for other parts (70 -> 80 and 80 -> 90), this is wildly inaccurate. This is because of the way lithium-ion batteries work at a chemical level and the way that the charge management controller inside the laptop actually handle charging the laptop (minimize heat, maximize efficiency). This leads to different charging policies such as CC -> CV transition where the charge controller switches from charging as fast as possible to as efficiently as possible as we approach a certain threshold.

The second factor is chemistry specific which is that no two batteries are exactly the same. How long it might take your laptop to charge from 20 -> 80% may be 1/2 the time it takes mine just due to the chemical wear and age of the battery, adapter used to charge it, temperature, etc. This is why macOS has the whole "Calculating..." where the BMS or "Battery Management System" is testing the battery's chemical state and the adapter to determine a reasonable estimate of how long it will take to charge the battery.

For us to arbitrarily change the end goal with a charge limiter but then not account for all of these other variables when calculating a new time, we are giving a very inaccurate estimate. The proper solution for this issue would be to query the BMS for all of this relevant information (through the SMC) and feed it into our own mathematical model with an arbitrary end-charge to determine the remaining time. Given the amount of fine-tuning required for this and inability to verify the results, I choose to not implement this when I first made the app.

I would rather we give the macOS time than a wildly inaccurate time.

If you want to pursue this, I suggest you look through the attached links in smc_power to try and find the relevant keys and build this estimator out, but it is a complex endeavor.

…eat/fix-battery-uptime-standalone

# Conflicts:
#	Stasis/ViewModels/MenuViewModel.swift
@DinanathDash

Copy link
Copy Markdown
Contributor Author

@srimanachanta Can you check the latest commit, and tell me if I was able to fix it or not?

@srimanachanta

Copy link
Copy Markdown
Owner

@srimanachanta Can you check the latest commit, and tell me if I was able to fix it or not?

The diff I see in this PR is that formatting is improved and the nil check is in place to stop showing "Calculating..." when charging is stopped. Is that correct? Can you remove the formatting changes as they are crowding the diff.

@srimanachanta

Copy link
Copy Markdown
Owner

Superceeded by 0a8f26d and ac41e08

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FR] Handle battery "not charging" status and format uptime as days/hours/mins

2 participants