From c4969b05eceabe6781d1a7ef0062ced2d7879e14 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 13 Jun 2026 11:55:19 -0400 Subject: [PATCH] fix: show daily budget instead of bogus clock countdown for limit rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- OpenAppLock/Logic/RuleStatus.swift | 16 ++++++++ OpenAppLock/Views/Apps/AppsHomeView.swift | 15 ++------ OpenAppLock/Views/Rules/RuleDetailSheet.swift | 2 +- OpenAppLockTests/RuleStatusTests.swift | 38 +++++++++++++++++++ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index bd39b34..2527b0d 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -82,6 +82,22 @@ extension BlockingRule { 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`. func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool { guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else { diff --git a/OpenAppLock/Views/Apps/AppsHomeView.swift b/OpenAppLock/Views/Apps/AppsHomeView.swift index 34d5b05..48c799c 100644 --- a/OpenAppLock/Views/Apps/AppsHomeView.swift +++ b/OpenAppLock/Views/Apps/AppsHomeView.swift @@ -231,18 +231,9 @@ struct AppsHomeView: View { } private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String { - switch rule.kind { - case .schedule: - return status.label(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) - } + // Shared with the detail sheet: limit rules that aren't blocking show + // their daily budget; everything else uses the live status label. + rule.statusLabel(for: status, relativeTo: now) } // MARK: - Enforcement diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift index 2ed5e99..767c174 100644 --- a/OpenAppLock/Views/Rules/RuleDetailSheet.swift +++ b/OpenAppLock/Views/Rules/RuleDetailSheet.swift @@ -89,7 +89,7 @@ struct RuleDetailSheet: View { Text(rule.name) .font(.headline) .accessibilityIdentifier("detailRuleName") - Text("\(rule.kind.displayName), \(status.label(relativeTo: now))") + Text("\(rule.kind.displayName), \(rule.statusLabel(for: status, relativeTo: now))") .font(.caption) .foregroundStyle(.secondary) .accessibilityIdentifier("detailStatusLabel") diff --git a/OpenAppLockTests/RuleStatusTests.swift b/OpenAppLockTests/RuleStatusTests.swift index f7ec581..8f72a42 100644 --- a/OpenAppLockTests/RuleStatusTests.swift +++ b/OpenAppLockTests/RuleStatusTests.swift @@ -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)) + } }