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.
This commit is contained in:
@@ -82,6 +82,22 @@ extension BlockingRule {
|
|||||||
return .dormant
|
return .dormant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-facing status label, kind-aware. Limit rules apply all day and have
|
||||||
|
/// no clock window, so while they are not blocking they show their daily
|
||||||
|
/// budget ("15m / day") instead of `.upcoming`'s vestigial start countdown.
|
||||||
|
/// Schedule rules, and any rule that is actually blocking/paused/dormant,
|
||||||
|
/// use the plain status label.
|
||||||
|
func statusLabel(for status: RuleStatus, relativeTo now: Date) -> String {
|
||||||
|
if case .upcoming = status {
|
||||||
|
switch kind {
|
||||||
|
case .schedule: break
|
||||||
|
case .timeLimit: return "\(dailyLimitMinutes)m / day"
|
||||||
|
case .openLimit: return "\(maxOpens) opens / day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status.label(relativeTo: now)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the rule's enabled days include the day containing `now`.
|
/// Whether the rule's enabled days include the day containing `now`.
|
||||||
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
|
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
|
||||||
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
|
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
|
||||||
|
|||||||
@@ -231,18 +231,9 @@ struct AppsHomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
||||||
switch rule.kind {
|
// Shared with the detail sheet: limit rules that aren't blocking show
|
||||||
case .schedule:
|
// their daily budget; everything else uses the live status label.
|
||||||
return status.label(relativeTo: now)
|
rule.statusLabel(for: status, relativeTo: now)
|
||||||
case .timeLimit:
|
|
||||||
// While blocking (or paused/disabled) show the live state; the
|
|
||||||
// budget is only informative while it is still being spent.
|
|
||||||
if case .upcoming = status { return "\(rule.dailyLimitMinutes)m / day" }
|
|
||||||
return status.label(relativeTo: now)
|
|
||||||
case .openLimit:
|
|
||||||
if case .upcoming = status { return "\(rule.maxOpens) opens / day" }
|
|
||||||
return status.label(relativeTo: now)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Enforcement
|
// MARK: - Enforcement
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ struct RuleDetailSheet: View {
|
|||||||
Text(rule.name)
|
Text(rule.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.accessibilityIdentifier("detailRuleName")
|
.accessibilityIdentifier("detailRuleName")
|
||||||
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
|
Text("\(rule.kind.displayName), \(rule.statusLabel(for: status, relativeTo: now))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.accessibilityIdentifier("detailStatusLabel")
|
.accessibilityIdentifier("detailStatusLabel")
|
||||||
|
|||||||
@@ -109,4 +109,42 @@ struct RuleStatusTests {
|
|||||||
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
|
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
|
||||||
#expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused")
|
#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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user