diff --git a/CHANGELOG.md b/CHANGELOG.md index 4015fc7b..8f82ade1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.8.3 - Unreleased +- Round compact stat badges (issue, PR, star, and release download counts) to the nearest thousand and million instead of truncating, so a count like 19,999 shows as "20K" rather than "19K" and matches GitHub's own compact numbers (thanks @LeoLin990405). (#81) + ## 0.8.2 - 2026-06-12 - Streamline the GitHub API submenu by showing sample age once, while moving shared-budget guidance into a dedicated API settings tab. diff --git a/Sources/RepoBar/Support/StatValueFormatter.swift b/Sources/RepoBar/Support/StatValueFormatter.swift index 52559e8b..82b9c253 100644 --- a/Sources/RepoBar/Support/StatValueFormatter.swift +++ b/Sources/RepoBar/Support/StatValueFormatter.swift @@ -8,16 +8,21 @@ enum StatValueFormatter { return "\(short)K" } if value < 1_000_000 { - return "\(value / 1000)K" + let thousands = self.rounded(value, divisor: 1000) + return thousands >= 1000 ? "1M" : "\(thousands)K" } if value < 10_000_000 { let short = self.oneDecimal(value, divisor: 1_000_000) return "\(short)M" } - if value >= 1_000_000_000 { - return "999M" - } - return "\(value / 1_000_000)M" + let millions = self.rounded(value, divisor: 1_000_000) + return millions >= 1000 ? "999M" : "\(millions)M" + } + + private static func rounded(_ value: Int, divisor: Int) -> Int { + // Round half up without an intermediate `value + divisor/2` that could + // overflow near Int.max: compare the remainder against half the divisor. + value / divisor + (value % divisor * 2 >= divisor ? 1 : 0) } private static func oneDecimal(_ value: Int, divisor: Double) -> String { @@ -26,9 +31,6 @@ enum StatValueFormatter { if formatted.hasSuffix(".0") { return String(formatted.dropLast(2)) } - if formatted.hasPrefix("10") { - return "10" - } return formatted } } diff --git a/Tests/RepoBarTests/StatValueFormatterTests.swift b/Tests/RepoBarTests/StatValueFormatterTests.swift new file mode 100644 index 00000000..b31ac2ed --- /dev/null +++ b/Tests/RepoBarTests/StatValueFormatterTests.swift @@ -0,0 +1,55 @@ +@testable import RepoBar +import Testing + +struct StatValueFormatterTests { + @Test + func `passes through values below one thousand`() { + #expect(StatValueFormatter.compact(0) == "0") + #expect(StatValueFormatter.compact(42) == "42") + #expect(StatValueFormatter.compact(999) == "999") + } + + @Test + func `keeps one decimal below ten thousand`() { + #expect(StatValueFormatter.compact(1000) == "1K") + #expect(StatValueFormatter.compact(1500) == "1.5K") + #expect(StatValueFormatter.compact(1999) == "2K") + #expect(StatValueFormatter.compact(9999) == "10K") + } + + @Test + func `rounds thousands to nearest instead of truncating`() { + #expect(StatValueFormatter.compact(10000) == "10K") + #expect(StatValueFormatter.compact(10500) == "11K") + #expect(StatValueFormatter.compact(19999) == "20K") + #expect(StatValueFormatter.compact(99500) == "100K") + #expect(StatValueFormatter.compact(123_456) == "123K") + } + + @Test + func `rolls rounded thousands up into millions`() { + #expect(StatValueFormatter.compact(999_499) == "999K") + #expect(StatValueFormatter.compact(999_500) == "1M") + #expect(StatValueFormatter.compact(999_999) == "1M") + } + + @Test + func `keeps one decimal below ten million`() { + #expect(StatValueFormatter.compact(1_000_000) == "1M") + #expect(StatValueFormatter.compact(1_500_000) == "1.5M") + #expect(StatValueFormatter.compact(9_999_999) == "10M") + } + + @Test + func `rounds millions and caps oversized counts`() { + #expect(StatValueFormatter.compact(10_500_000) == "11M") + #expect(StatValueFormatter.compact(99_999_999) == "100M") + #expect(StatValueFormatter.compact(999_000_000) == "999M") + #expect(StatValueFormatter.compact(1_000_000_000) == "999M") + } + + @Test + func `caps Int.max without integer overflow`() { + #expect(StatValueFormatter.compact(Int.max) == "999M") + } +}