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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion desktop/macos/CHANGELOG.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"unreleased": [],
"unreleased": [
"Fixed goal progress rings and labels showing an incorrect value when a tracked value dropped below its starting point"
],
"releases": [
{
"version": "0.11.467",
Expand Down
3 changes: 2 additions & 1 deletion desktop/macos/Desktop/Sources/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2727,7 +2727,8 @@ struct Goal: Codable, Identifiable {
/// Progress as a percentage (0-100), based on targetValue
var progress: Double {
guard targetValue != minValue else { return 0 }
return ((currentValue - minValue) / (targetValue - minValue)) * 100.0
let pct = ((currentValue - minValue) / (targetValue - minValue)) * 100.0
return min(max(pct, 0), 100)
}

/// Whether the goal is completed
Expand Down
48 changes: 48 additions & 0 deletions desktop/macos/Desktop/Tests/GoalProgressTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import XCTest

@testable import Omi_Computer

/// Unit tests for `Goal.progress`, which must stay within its documented
/// 0-100 range. Regression coverage for a bug where `current_value` below
/// `min_value` produced a negative percentage that leaked into the goal
/// progress ring (`Path.trim`) and the "% complete" prompt text.
final class GoalProgressTests: XCTestCase {

/// Decode a `Goal` from minimal JSON. Dates are omitted so they fall back
/// to the decoder defaults — `progress` does not depend on them.
private func makeGoal(min: Double, target: Double, current: Double) throws -> Goal {
let json = """
{
"id": "g1",
"goal_type": "numeric",
"min_value": \(min),
"target_value": \(target),
"current_value": \(current)
}
"""
return try JSONDecoder().decode(Goal.self, from: Data(json.utf8))
}

func testProgressMidRange() throws {
let goal = try makeGoal(min: 0, target: 10, current: 5)
XCTAssertEqual(goal.progress, 50, accuracy: 0.0001)
}

func testProgressClampsNegativeToZero() throws {
// current below min would yield (2-5)/(15-5)*100 = -30 without clamping.
let goal = try makeGoal(min: 5, target: 15, current: 2)
XCTAssertEqual(goal.progress, 0, accuracy: 0.0001, "Progress must not go below 0")
}

func testProgressClampsOverachievementToHundred() throws {
// current above target would yield 200 without clamping.
let goal = try makeGoal(min: 0, target: 10, current: 20)
XCTAssertEqual(goal.progress, 100, accuracy: 0.0001, "Progress must not exceed 100")
}

func testProgressIsZeroWhenTargetEqualsMin() throws {
// Guard against divide-by-zero when the range is degenerate.
let goal = try makeGoal(min: 5, target: 5, current: 5)
XCTAssertEqual(goal.progress, 0, accuracy: 0.0001)
}
}
Loading