fix: show daily budget instead of bogus clock countdown for limit rules

Time-limit and open-limit rules reuse BlockingRule's startMinutes/
endMinutes, which default to 09:00-17:00. Those fields are meaningless
for limit rules (the budget applies all day, resetting at midnight), but
status() still falls through to schedule.nextStart(), so an idle limit
rule reported .upcoming(startsAt: next 09:00) — rendered in the detail
sheet header as "Starts in 22h".

The home card already special-cased this; the detail sheet rendered the
raw status label, so the misleading countdown leaked there. Centralize a
kind-aware BlockingRule.statusLabel(for:relativeTo:) used by both the
card and the detail header: limit rules that aren't blocking show their
daily budget ("45m / day", "5 opens / day"); schedule rules and any
blocking/paused/dormant rule keep the live status label.

Verified on simulator: the Time Keeper detail header now reads
"Time Limit, 45m / day". 173 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:55:19 -04:00
parent 05e0ee2755
commit c4969b05ec
4 changed files with 58 additions and 13 deletions

View File

@@ -109,4 +109,42 @@ struct RuleStatusTests {
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
#expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused")
}
// MARK: - Kind-aware display label
/// A non-blocking time-limit rule has no clock window, so it must show its
/// daily budget never the vestigial 09:00 start as "Starts in 22h".
@Test("Idle time-limit rule shows its daily budget, not a clock countdown")
func timeLimitDisplayLabel() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, dailyLimitMinutes: 15)
let now = date(2025, 1, 6, 11, 38) // past the vestigial 09:00 window start
let status = rule.status(at: now, calendar: utc)
#expect(rule.statusLabel(for: status, relativeTo: now) == "15m / day")
}
@Test("Idle open-limit rule shows its daily opens budget")
func openLimitDisplayLabel() {
let rule = BlockingRule(name: "Gate Keeper", kind: .openLimit, maxOpens: 5)
let now = date(2025, 1, 6, 11, 38)
let status = rule.status(at: now, calendar: utc)
#expect(rule.statusLabel(for: status, relativeTo: now) == "5 opens / day")
}
@Test("Schedule rule still shows the clock countdown")
func scheduleDisplayLabelUnchanged() {
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
let friday = date(2025, 1, 10, 11, 28)
let status = weekend.status(at: friday, calendar: utc)
#expect(weekend.statusLabel(for: status, relativeTo: friday) == "Starts in 22h")
}
@Test("A spent time-limit budget still shows the blocked countdown")
func timeLimitBlockingDisplayLabel() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, dailyLimitMinutes: 15)
let now = date(2025, 1, 6, 11, 38)
let status = rule.status(at: now, calendar: utc, usage: RuleUsage(minutesUsed: 15))
#expect(status.isActive)
// Stays on the live countdown ("Xh left"), not overridden to the budget.
#expect(rule.statusLabel(for: status, relativeTo: now) == status.label(relativeTo: now))
}
}