mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 12:13:59 +00:00
feat(bar-app): bar-first quota card with sane time formatting
Replace the prose window lines with an aligned per-window bar list (5h / weekly / Opus / Sonnet), colored by headroom, with the binding window highlighted and a compact reset chip. Humanize durations beyond a day (Nd Nh instead of raw hours) and only show the burn-rate pace clause when a window is projected to exhaust before it resets, so a meaningless 'resets in 44h' / '~110h left' projection no longer appears.
This commit is contained in:
1 parent
a5579f84fe
commit
df967792b9
4 files changed
+309
-177
No files matched your search
@@ -392,6 +392,17 @@ do {
|
||||
check(a.monthToDate.cost != a.last30d.cost, "monthToDate is distinct from rolling last30d")
|
||||
}
|
||||
|
||||
// MARK: compactDuration three-tier formatting
|
||||
|
||||
check(BarQuotaGauge.compactDuration(minutes: 35) == "35m", "compactDuration <1h -> minutes only")
|
||||
check(BarQuotaGauge.compactDuration(minutes: 60) == "1h 0m", "compactDuration 1h exactly")
|
||||
check(BarQuotaGauge.compactDuration(minutes: 195) == "3h 15m", "compactDuration hours+minutes")
|
||||
// 44h 38m = 2678 min → 1d 20h (44%24=20, 44/24=1)
|
||||
check(BarQuotaGauge.compactDuration(minutes: 2678) == "1d 20h", "compactDuration 44h38m -> '1d 20h'")
|
||||
// 110h 27m = 6627 min → 4d 14h (110/24=4, 110%24=14)
|
||||
check(BarQuotaGauge.compactDuration(minutes: 6627) == "4d 14h", "compactDuration 110h27m -> '4d 14h'")
|
||||
check(BarQuotaGauge.compactDuration(minutes: 1440) == "1d 0h", "compactDuration exactly 24h -> '1d 0h'")
|
||||
|
||||
// MARK: BarQuotaGauge band + fillFraction + countdown
|
||||
|
||||
check(BarQuotaGauge.band(percentage: 82, status: "ok") == .green, "band >50 -> green")
|
||||
@@ -426,6 +437,17 @@ do {
|
||||
check(
|
||||
BarQuotaGauge.resetCountdown(nextReset: iso12, now: now) == "resets in 12m",
|
||||
"resetCountdown formats minutes-only")
|
||||
// Days tier: >=24h -> "Nd Nh" — 44h 38m -> "1d 20h", 110h 27m -> "4d 14h".
|
||||
let in44h38m = now.addingTimeInterval(44 * 3600 + 38 * 60)
|
||||
let iso44h38m = ISO8601DateFormatter().string(from: in44h38m)
|
||||
check(
|
||||
BarQuotaGauge.resetCountdown(nextReset: iso44h38m, now: now) == "resets in 1d 20h",
|
||||
"resetCountdown >=24h -> days tier '1d 20h'")
|
||||
let in110h27m = now.addingTimeInterval(110 * 3600 + 27 * 60)
|
||||
let iso110h27m = ISO8601DateFormatter().string(from: in110h27m)
|
||||
check(
|
||||
BarQuotaGauge.resetCountdown(nextReset: iso110h27m, now: now) == "resets in 4d 14h",
|
||||
"resetCountdown large duration '4d 14h'")
|
||||
let past = now.addingTimeInterval(-60)
|
||||
let isoPast = ISO8601DateFormatter().string(from: past)
|
||||
check(
|
||||
@@ -1106,14 +1128,25 @@ do {
|
||||
|
||||
do {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
|
||||
|
||||
// Finite projection -> "~Hh Mm left at this pace".
|
||||
// At-risk scenario: window=300min, reset in 200min → elapsed=100min.
|
||||
// usedPercent=75 → burn = (100-75)*100/75 ≈ 33 min. 33 < 200 → at-risk → shows pace.
|
||||
let resetIn200 = ISO8601DateFormatter().string(from: now.addingTimeInterval(200 * 60))
|
||||
let finite = BarQuotaGauge.paceClause(
|
||||
usedPercent: 50, remainingPercent: 50, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(finite == "~2h 30m left at this pace", "pace: finite -> '~2h 30m left at this pace'")
|
||||
usedPercent: 75, remainingPercent: 25, resetAt: resetIn200, windowMinutes: 300, now: now)
|
||||
check(finite?.hasPrefix("~") == true && finite?.hasSuffix("left at this pace") == true,
|
||||
"pace: at-risk (burn < reset) -> shows pace clause")
|
||||
|
||||
// NOT at-risk: burn > minutesToReset — pace must be nil (the core fix).
|
||||
// window=300min, reset in 50min → elapsed=250min. usedPercent=50 →
|
||||
// burn = (100-50)*250/50 = 250 min. 250 > 50 → NOT at risk → nil.
|
||||
let resetIn50 = ISO8601DateFormatter().string(from: now.addingTimeInterval(50 * 60))
|
||||
let notAtRisk = BarQuotaGauge.paceClause(
|
||||
usedPercent: 50, remainingPercent: 50, resetAt: resetIn50, windowMinutes: 300, now: now)
|
||||
check(notAtRisk == nil, "pace: burn > reset (not at risk) -> nil (omit — core bug fix)")
|
||||
|
||||
// Lots of headroom -> "plenty at this pace".
|
||||
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
|
||||
let plenty = BarQuotaGauge.paceClause(
|
||||
usedPercent: 10, remainingPercent: 90, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(plenty == "plenty at this pace", "pace: >=85% remaining -> 'plenty at this pace'")
|
||||
@@ -1131,7 +1164,8 @@ do {
|
||||
"pace: 0% remaining -> 'limit reached, resets in ...'")
|
||||
|
||||
// < 5 min floor -> limit-reached path, never a scary "~0m".
|
||||
// usedPercent 99 over 150min elapsed -> ~1.5 min remaining (<5).
|
||||
// window=300min, reset in 150min → elapsed=150min. usedPercent=99 →
|
||||
// burn = (100-99)*150/99 ≈ 1.5 min → floor hit → "limit reached".
|
||||
let nearFloor = BarQuotaGauge.paceClause(
|
||||
usedPercent: 99, remainingPercent: 1, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
check(
|
||||
@@ -1150,6 +1184,48 @@ do {
|
||||
check(past == nil, "pace: past resetAt -> nil pace")
|
||||
}
|
||||
|
||||
// MARK: atRisk boolean
|
||||
|
||||
do {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
// At-risk: burn(33m) < minutesToReset(200m) → true.
|
||||
let resetIn200 = ISO8601DateFormatter().string(from: now.addingTimeInterval(200 * 60))
|
||||
let isAtRisk = BarQuotaGauge.atRisk(
|
||||
usedPercent: 75, remainingPercent: 25, resetAt: resetIn200, windowMinutes: 300, now: now)
|
||||
check(isAtRisk == true, "atRisk: burn < reset -> true")
|
||||
|
||||
// NOT at-risk: burn(250m) > minutesToReset(50m) → false.
|
||||
let resetIn50 = ISO8601DateFormatter().string(from: now.addingTimeInterval(50 * 60))
|
||||
let notAtRisk = BarQuotaGauge.atRisk(
|
||||
usedPercent: 50, remainingPercent: 50, resetAt: resetIn50, windowMinutes: 300, now: now)
|
||||
check(notAtRisk == false, "atRisk: burn > reset -> false")
|
||||
|
||||
// Exhausted (remaining=0) → false (handled by limit-reached path, not atRisk).
|
||||
let resetIn150 = ISO8601DateFormatter().string(from: now.addingTimeInterval(150 * 60))
|
||||
check(
|
||||
BarQuotaGauge.atRisk(
|
||||
usedPercent: 100, remainingPercent: 0, resetAt: resetIn150, windowMinutes: 300, now: now)
|
||||
== false,
|
||||
"atRisk: exhausted -> false")
|
||||
|
||||
// Unknown window → false.
|
||||
check(
|
||||
BarQuotaGauge.atRisk(
|
||||
usedPercent: 75, remainingPercent: 25, resetAt: nil, windowMinutes: nil, now: now)
|
||||
== false,
|
||||
"atRisk: unknown window -> false")
|
||||
|
||||
// Large weekly burn in screenshot scenario: weekly window resets in ~9h 2m (542 min),
|
||||
// usedPercent=59 over elapsed=(10080-542)=9538 min → burn ≈ (41*9538)/59 ≈ 6626 min.
|
||||
// 6626 > 542 → NOT at risk (this was the nonsensical "~110h left" shown to user).
|
||||
let resetIn542m = ISO8601DateFormatter().string(from: now.addingTimeInterval(542 * 60))
|
||||
let weeklyNotAtRisk = BarQuotaGauge.atRisk(
|
||||
usedPercent: 59, remainingPercent: 41, resetAt: resetIn542m, windowMinutes: 10080, now: now)
|
||||
check(weeklyNotAtRisk == false,
|
||||
"atRisk: weekly window resetting in 9h, burn projecting 100+h -> false (screenshot fix)")
|
||||
}
|
||||
|
||||
// MARK: headroomLeader
|
||||
|
||||
do {
|
||||
|
||||
Reference in new issue
Block a user