From df6b7b689dbb8de13862e1ce40ece5a226af95a8 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 12 Jun 2026 20:22:48 -0400 Subject: [PATCH] feat: track limit budgets in a usage ledger and surface them in a Usage section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RuleUsage + UsageLedger: per-rule, per-day minutes/opens in app-group defaults (monotonic minutes, incrementing opens, midnight rollover by day-keying); MockUsageLedger for tests and seeded UI scenarios - usage-aware status: a limit rule whose daily budget is spent on an enabled day is active (blocking) until next midnight; Hard Mode gating and app-list locking honor it; soft unblock pauses until midnight - RuleEnforcer shields spent limit rules (always Block mode) while the app runs - new Usage section under Blocked Apps: '18m of 45m used today · 27m left', '2 of 5 opens today · 3 opens left', 'Blocked until tomorrow' - new 'limits' seed scenario + UI tests Co-Authored-By: Claude --- OpenAppLock/Logic/RulePolicy.swift | 64 +++-- OpenAppLock/Logic/RuleStatus.swift | 45 +++- OpenAppLock/Logic/UsageDisplay.swift | 40 ++++ OpenAppLock/OpenAppLockApp.swift | 8 +- OpenAppLock/Services/AppGroup.swift | 19 ++ .../Services/LaunchConfiguration.swift | 4 + OpenAppLock/Services/RuleEnforcer.swift | 32 ++- OpenAppLock/Services/SampleRules.swift | 40 +++- OpenAppLock/Services/UsageLedger.swift | 93 ++++++++ .../Views/AppLists/AppListPickerSheet.swift | 8 +- OpenAppLock/Views/Apps/AppsHomeView.swift | 72 +++++- OpenAppLock/Views/Rules/RuleDetailSheet.swift | 6 +- OpenAppLockTests/UsageTests.swift | 222 ++++++++++++++++++ OpenAppLockUITests/UsageUITests.swift | 49 ++++ 14 files changed, 640 insertions(+), 62 deletions(-) create mode 100644 OpenAppLock/Logic/UsageDisplay.swift create mode 100644 OpenAppLock/Services/AppGroup.swift create mode 100644 OpenAppLock/Services/UsageLedger.swift create mode 100644 OpenAppLockTests/UsageTests.swift create mode 100644 OpenAppLockUITests/UsageUITests.swift diff --git a/OpenAppLock/Logic/RulePolicy.swift b/OpenAppLock/Logic/RulePolicy.swift index 60be668..c644354 100644 --- a/OpenAppLock/Logic/RulePolicy.swift +++ b/OpenAppLock/Logic/RulePolicy.swift @@ -8,46 +8,55 @@ import Foundation /// Gates every mutation of a rule. This is where Hard Mode is enforced: /// while a hard-mode rule is actively blocking, nothing about it can be /// weakened until the window ends. +/// +/// Limit rules block on spent usage rather than the clock, so their gates +/// take the day's `RuleUsage`; passing nil treats them as not blocking. enum RulePolicy { /// True while the rule is actively blocking with Hard Mode on. static func isHardLocked( - _ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + _ rule: BlockingRule, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - rule.hardMode && rule.status(at: now, calendar: calendar).isActive + rule.hardMode && rule.status(at: now, calendar: calendar, usage: usage).isActive } static func canEdit( - _ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + _ rule: BlockingRule, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - !isHardLocked(rule, at: now, calendar: calendar) + !isHardLocked(rule, usage: usage, at: now, calendar: calendar) } static func canDisable( - _ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + _ rule: BlockingRule, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - !isHardLocked(rule, at: now, calendar: calendar) + !isHardLocked(rule, usage: usage, at: now, calendar: calendar) } static func canDelete( - _ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + _ rule: BlockingRule, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - !isHardLocked(rule, at: now, calendar: calendar) + !isHardLocked(rule, usage: usage, at: now, calendar: calendar) } /// Whether the user may lift the current block early ("Unblock"). - /// Requires an active window and Hard Mode off. + /// Requires an active block and Hard Mode off. static func canUnblock( - _ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + _ rule: BlockingRule, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - rule.status(at: now, calendar: calendar).isActive && !rule.hardMode + rule.status(at: now, calendar: calendar, usage: usage).isActive && !rule.hardMode } /// Hard Mode can always be turned on, but never off while the rule is /// actively blocking — that is the whole point of a hard block. static func canTurnOffHardMode( - _ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + _ rule: BlockingRule, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - !isHardLocked(rule, at: now, calendar: calendar) + !isHardLocked(rule, usage: usage, at: now, calendar: calendar) } /// App lists feed active shields, so while any hard-mode rule is actively @@ -55,21 +64,32 @@ enum RulePolicy { /// back door out of the hard block. Creating new lists and picking lists /// for other rules stay allowed; they cannot weaken an active block. static func canEditAppLists( - rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current + rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil }, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - !rules.contains { isHardLocked($0, at: now, calendar: calendar) } + !rules.contains { + isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar) + } } - /// Pauses the rule's current window. Returns false (and changes nothing) - /// when unblocking is not allowed. + /// Pauses the rule's current block. Returns false (and changes nothing) + /// when unblocking is not allowed. Schedule rules re-arm at their next + /// window; limit rules re-arm at midnight with the next day's budget. @discardableResult static func unblock( - _ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + _ rule: BlockingRule, usage: RuleUsage? = nil, + at now: Date = .now, calendar: Calendar = .current ) -> Bool { - guard canUnblock(rule, at: now, calendar: calendar), - let window = rule.schedule.activeWindow(containing: now, calendar: calendar) - else { return false } - rule.pausedUntil = window.end + guard canUnblock(rule, usage: usage, at: now, calendar: calendar) else { return false } + switch rule.kind { + case .schedule: + guard let window = rule.schedule.activeWindow(containing: now, calendar: calendar) + else { return false } + rule.pausedUntil = window.end + case .timeLimit, .openLimit: + guard let midnight = calendar.nextMidnight(after: now) else { return false } + rule.pausedUntil = midnight + } return true } } diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index ff52cab..bd39b34 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -45,14 +45,25 @@ enum RuleStatus: Equatable, Sendable { } extension BlockingRule { - /// Live status of this rule. Schedule rules derive it from their time window; - /// time/open-limit rules report upcoming based on their enabled days only - /// (their blocking is triggered by usage, not the clock). - func status(at now: Date = .now, calendar: Calendar = .current) -> RuleStatus { + /// Live status of this rule. Schedule rules derive it from their time + /// window. Time/open-limit rules derive it from the day's usage: once the + /// budget is spent on an enabled day they are active (blocking) until the + /// next midnight; without usage data they report upcoming. + func status( + at now: Date = .now, calendar: Calendar = .current, usage: RuleUsage? = nil + ) -> RuleStatus { guard isEnabled else { return .disabled } guard !days.isEmpty else { return .dormant } guard kind == .schedule else { + if let usage, isScheduledToday(at: now, calendar: calendar), + limitReached(given: usage), + let midnight = calendar.nextMidnight(after: now) { + if let pausedUntil, pausedUntil > now { + return .paused(until: min(pausedUntil, midnight)) + } + return .active(until: midnight) + } guard let next = schedule.nextStart(after: now, calendar: calendar) else { return .dormant } @@ -70,4 +81,30 @@ extension BlockingRule { } return .dormant } + + /// 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 { + return false + } + return days.contains(weekday) + } + + /// Whether the given usage exhausts this rule's daily budget. + /// Always false for schedule rules — they block by the clock. + func limitReached(given usage: RuleUsage) -> Bool { + switch kind { + case .schedule: false + case .timeLimit: usage.minutesUsed >= dailyLimitMinutes + case .openLimit: usage.opensUsed >= maxOpens + } + } +} + +extension Calendar { + /// The first instant of the day after the one containing `date` — the + /// "Tomorrow" reset point for spent limit budgets. + func nextMidnight(after date: Date) -> Date? { + self.date(byAdding: .day, value: 1, to: startOfDay(for: date)) + } } diff --git a/OpenAppLock/Logic/UsageDisplay.swift b/OpenAppLock/Logic/UsageDisplay.swift new file mode 100644 index 0000000..26fce19 --- /dev/null +++ b/OpenAppLock/Logic/UsageDisplay.swift @@ -0,0 +1,40 @@ +// +// UsageDisplay.swift +// OpenAppLock +// + +import Foundation + +/// Strings for the home screen's Usage section. Used values clamp to the +/// budget so overshoot (thresholds can fire late) never reads "50m of 45m". +enum UsageDisplay { + /// "18m of 45m used today" / "2 of 5 opens today". + static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String { + switch rule.kind { + case .schedule: + "" + case .timeLimit: + "\(min(usage.minutesUsed, rule.dailyLimitMinutes))m of " + + "\(rule.dailyLimitMinutes)m used today" + case .openLimit: + "\(min(usage.opensUsed, rule.maxOpens)) of \(rule.maxOpens) opens today" + } + } + + /// "27m left" / "3 opens left", or the blocked/unblocked state once the + /// budget is spent. + static func remainingLabel(for rule: BlockingRule, usage: RuleUsage, isPaused: Bool) -> String { + guard !rule.limitReached(given: usage) else { + return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow" + } + switch rule.kind { + case .schedule: + return "" + case .timeLimit: + return "\(rule.dailyLimitMinutes - usage.minutesUsed)m left" + case .openLimit: + let remaining = rule.maxOpens - usage.opensUsed + return remaining == 1 ? "1 open left" : "\(remaining) opens left" + } + } +} diff --git a/OpenAppLock/OpenAppLockApp.swift b/OpenAppLock/OpenAppLockApp.swift index 5c1ae3d..40da66a 100644 --- a/OpenAppLock/OpenAppLockApp.swift +++ b/OpenAppLock/OpenAppLockApp.swift @@ -31,8 +31,12 @@ struct OpenAppLockApp: App { fatalError("Could not create ModelContainer: \(error)") } + let usageLedger: UsageReading = config.isUITesting ? MockUsageLedger() : UsageLedger() + if let scenario = config.seedScenario { - SampleRules.seed(scenario, into: container.mainContext) + SampleRules.seed( + scenario, into: container.mainContext, usage: usageLedger as? MockUsageLedger + ) } AppListMigration.run(in: container.mainContext) @@ -46,7 +50,7 @@ struct OpenAppLockApp: App { let shields: ShieldApplying = config.isUITesting ? MockShieldController() : ManagedSettingsShieldController() - _enforcer = State(initialValue: RuleEnforcer(shields: shields)) + _enforcer = State(initialValue: RuleEnforcer(shields: shields, usage: usageLedger)) } var body: some Scene { diff --git a/OpenAppLock/Services/AppGroup.swift b/OpenAppLock/Services/AppGroup.swift new file mode 100644 index 0000000..83f1632 --- /dev/null +++ b/OpenAppLock/Services/AppGroup.swift @@ -0,0 +1,19 @@ +// +// AppGroup.swift +// OpenAppLock +// + +import Foundation + +/// The app group shared by the app and its Screen Time extensions. Usage +/// tracking and rule snapshots live in its UserDefaults suite so the +/// DeviceActivity monitor and shield extensions can read and write them. +enum AppGroup { + static let identifier = "group.dev.bchen.OpenAppLock" + + /// Shared defaults; falls back to standard defaults when the group + /// container is unavailable (e.g. entitlement not provisioned yet). + static var defaults: UserDefaults { + UserDefaults(suiteName: identifier) ?? .standard + } +} diff --git a/OpenAppLock/Services/LaunchConfiguration.swift b/OpenAppLock/Services/LaunchConfiguration.swift index 8cb9253..d0872f8 100644 --- a/OpenAppLock/Services/LaunchConfiguration.swift +++ b/OpenAppLock/Services/LaunchConfiguration.swift @@ -13,6 +13,10 @@ struct LaunchConfiguration: Equatable { case standard /// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule. case hardModeActive = "hard-mode-active" + /// Limit rules with seeded usage: "Time Keeper" (18m of 45m), + /// "Gate Keeper" (2 of 5 opens), and "Doom Scroll" (budget spent → + /// blocked until tomorrow). + case limits } var isUITesting = false diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index 0991a23..cf2d879 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -7,18 +7,32 @@ import Foundation import Observation /// Turns the current set of rules into shield state: schedule rules with an -/// active, un-paused window are shielded; everything else is cleared. +/// active, un-paused window are shielded, and limit rules whose daily budget +/// is spent (per the usage ledger) are shielded until midnight; everything +/// else is cleared. /// -/// Time-limit and open-limit rules need a DeviceActivity monitor extension to -/// react to usage thresholds in the background; they are persisted and shown -/// in the UI but not yet enforced here. +/// Background transitions (and usage tracking itself) belong to the +/// DeviceActivity monitor extension; this keeps shields correct while the +/// app runs. @Observable final class RuleEnforcer { private(set) var blockingRuleIDs: Set = [] private let shields: ShieldApplying + /// Day-usage source consulted for limit rules; also exposed to views for + /// the Usage section. + let usageReader: UsageReading - init(shields: ShieldApplying) { + init(shields: ShieldApplying, usage: UsageReading = UsageLedger()) { self.shields = shields + self.usageReader = usage + } + + /// The day's usage for a rule (nil for schedule rules, which don't track). + func usage( + for rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current + ) -> RuleUsage? { + guard rule.kind != .schedule else { return nil } + return usageReader.usage(for: rule.id, onDayContaining: now, calendar: calendar) } /// Recomputes shields from scratch. Call on launch, on any rule change, @@ -29,14 +43,14 @@ final class RuleEnforcer { if let pausedUntil = rule.pausedUntil, pausedUntil <= now { rule.pausedUntil = nil } - guard rule.kind == .schedule, - rule.status(at: now, calendar: calendar).isActive - else { continue } + let usage = usage(for: rule, at: now, calendar: calendar) + guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue } active.insert(rule.id) shields.applyShield( ruleID: rule.id, selectionData: rule.appList?.selectionData, - mode: rule.selectionMode, + // Allow Only is a schedule-rule concept; limit blocks always Block. + mode: rule.kind == .schedule ? rule.selectionMode : .block, blockAdultContent: rule.blockAdultContent ) } diff --git a/OpenAppLock/Services/SampleRules.swift b/OpenAppLock/Services/SampleRules.swift index 9a88f8f..82eea85 100644 --- a/OpenAppLock/Services/SampleRules.swift +++ b/OpenAppLock/Services/SampleRules.swift @@ -12,6 +12,7 @@ enum SampleRules { static func seed( _ scenario: LaunchConfiguration.SeedScenario, into context: ModelContext, + usage: MockUsageLedger? = nil, now: Date = .now, calendar: Calendar = .current ) { @@ -20,19 +21,32 @@ enum SampleRules { let distractions = AppList(name: "Distractions", selectionCount: 3) context.insert(distractions) - let rules = - switch scenario { - case .standard: - [ - activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar), - upcomingRule(named: "Sleep", now: now, calendar: calendar), - ] - case .hardModeActive: - [ - activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar), - upcomingRule(named: "Sleep", now: now, calendar: calendar), - ] - } + let rules: [BlockingRule] + switch scenario { + case .standard: + rules = [ + activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar), + upcomingRule(named: "Sleep", now: now, calendar: calendar), + ] + case .hardModeActive: + rules = [ + activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar), + upcomingRule(named: "Sleep", now: now, calendar: calendar), + ] + case .limits: + let timeKeeper = BlockingRule( + name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, + dailyLimitMinutes: 45) + let gateKeeper = BlockingRule( + name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5) + let doomScroll = BlockingRule( + name: "Doom Scroll", kind: .timeLimit, days: Weekday.everyDay, + dailyLimitMinutes: 30) + usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18) + usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2) + usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30) + rules = [timeKeeper, gateKeeper, doomScroll] + } // Relationships are wired only after both sides are managed // (see BlockingRule.appList). for rule in rules { diff --git a/OpenAppLock/Services/UsageLedger.swift b/OpenAppLock/Services/UsageLedger.swift new file mode 100644 index 0000000..1dd1a05 --- /dev/null +++ b/OpenAppLock/Services/UsageLedger.swift @@ -0,0 +1,93 @@ +// +// UsageLedger.swift +// OpenAppLock +// + +import Foundation + +/// What a limit rule has consumed on a given day. Written by the +/// DeviceActivity monitor (minutes) and shield-action extension (opens); +/// read by the app for display and enforcement. +struct RuleUsage: Codable, Equatable { + var minutesUsed = 0 + var opensUsed = 0 +} + +/// Read access to per-rule, per-day usage. +protocol UsageReading: AnyObject { + func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsage +} + +extension UsageReading { + func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsage { + usage(for: ruleID, onDayContaining: date, calendar: .current) + } +} + +/// Usage bookkeeping in the shared app-group defaults, keyed by calendar day +/// and rule. Old days are simply ignored; midnight needs no reset step. +final class UsageLedger: UsageReading { + private let defaults: UserDefaults + + init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + /// "2026-06-12" — calendar-date key so budgets roll over at midnight. + static func dayKey(for date: Date, calendar: Calendar = .current) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + } + + func usage( + for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current + ) -> RuleUsage { + guard let data = defaults.data(forKey: key(ruleID, date, calendar)), + let usage = try? JSONDecoder().decode(RuleUsage.self, from: data) + else { return RuleUsage() } + return usage + } + + func setUsage( + _ usage: RuleUsage, for ruleID: UUID, onDayContaining date: Date, + calendar: Calendar = .current + ) { + guard let data = try? JSONEncoder().encode(usage) else { return } + defaults.set(data, forKey: key(ruleID, date, calendar)) + } + + /// Threshold events report cumulative totals, so minutes only move up. + func recordMinutesUsed( + _ minutes: Int, for ruleID: UUID, onDayContaining date: Date, + calendar: Calendar = .current + ) { + var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar) + usage.minutesUsed = max(usage.minutesUsed, minutes) + setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) + } + + @discardableResult + func recordOpen( + for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current + ) -> RuleUsage { + var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar) + usage.opensUsed += 1 + setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) + return usage + } + + private func key(_ ruleID: UUID, _ date: Date, _ calendar: Calendar) -> String { + "usage/\(Self.dayKey(for: date, calendar: calendar))/\(ruleID.uuidString)" + } +} + +/// Seedable in-memory usage for tests and UI-test scenarios. +final class MockUsageLedger: UsageReading { + var usageByRule: [UUID: RuleUsage] = [:] + + func usage( + for ruleID: UUID, onDayContaining date: Date, calendar: Calendar + ) -> RuleUsage { + usageByRule[ruleID] ?? RuleUsage() + } +} diff --git a/OpenAppLock/Views/AppLists/AppListPickerSheet.swift b/OpenAppLock/Views/AppLists/AppListPickerSheet.swift index bda34c0..47f58c6 100644 --- a/OpenAppLock/Views/AppLists/AppListPickerSheet.swift +++ b/OpenAppLock/Views/AppLists/AppListPickerSheet.swift @@ -15,6 +15,7 @@ struct AppListPickerSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext + @Environment(RuleEnforcer.self) private var enforcer @Query(sort: \AppList.createdAt) private var lists: [AppList] @Query private var rules: [BlockingRule] @@ -22,10 +23,11 @@ struct AppListPickerSheet: View { @State private var creatingList = false @State private var deletionBlocked = false - /// While any hard-mode rule is actively blocking, lists are read-only — - /// editing one would be a back door out of the hard block. + /// While any hard-mode rule is actively blocking (by the clock or a spent + /// limit budget), lists are read-only — editing one would be a back door + /// out of the hard block. private var listsLocked: Bool { - !RulePolicy.canEditAppLists(rules: rules) + !RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) }) } var body: some View { diff --git a/OpenAppLock/Views/Apps/AppsHomeView.swift b/OpenAppLock/Views/Apps/AppsHomeView.swift index f7339e3..34d5b05 100644 --- a/OpenAppLock/Views/Apps/AppsHomeView.swift +++ b/OpenAppLock/Views/Apps/AppsHomeView.swift @@ -50,7 +50,7 @@ struct AppsHomeView: View { ) { Button("Unblock", role: .destructive) { if let rule = unblockCandidate { - RulePolicy.unblock(rule) + RulePolicy.unblock(rule, usage: enforcer.usage(for: rule)) refreshEnforcement() } unblockCandidate = nil @@ -74,15 +74,22 @@ struct AppsHomeView: View { private func ruleList(now: Date) -> some View { List { blockedSection(now: now) + usageSection(now: now) rulesSection(now: now) } } + /// Status with the day's usage folded in, so limit rules whose budget is + /// spent count as actively blocking. + private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus { + rule.status(at: now, usage: enforcer.usage(for: rule, at: now)) + } + // MARK: - Blocked Apps @ViewBuilder private func blockedSection(now: Date) -> some View { - let blocking = rules.filter { $0.status(at: now).isActive } + let blocking = rules.filter { liveStatus(for: $0, now: now).isActive } Section { if blocking.isEmpty { Text("Nothing is blocked right now.") @@ -100,7 +107,7 @@ struct AppsHomeView: View { private func blockedRow(for rule: BlockingRule, now: Date) -> some View { Button { - if RulePolicy.canUnblock(rule, at: now) { + if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) { unblockCandidate = rule } else { hardModeBlockedAttempt = true @@ -120,6 +127,53 @@ struct AppsHomeView: View { .accessibilityIdentifier("blockedTile-\(rule.name)") } + // MARK: - Usage + + /// Live tracking for every limit rule scheduled today: how much of the + /// daily budget is spent and what remains. + @ViewBuilder + private func usageSection(now: Date) -> some View { + let tracked = rules.filter { + $0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now) + } + if !tracked.isEmpty { + Section { + ForEach(tracked) { rule in + usageRow(for: rule, now: now) + } + } header: { + Text("Usage").textCase(nil) + } + } + } + + private func usageRow(for rule: BlockingRule, now: Date) -> some View { + let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage() + let isPaused = + if case .paused = liveStatus(for: rule, now: now) { true } else { false } + return HStack { + Image(systemName: rule.kind.symbolName) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(rule.name) + .foregroundStyle(Color.primary) + Text(UsageDisplay.subtitle(for: rule, usage: usage)) + .font(.caption) + .foregroundStyle(Color.secondary) + } + Spacer() + Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused)) + .font(.subheadline) + .foregroundStyle( + rule.limitReached(given: usage) && !isPaused + ? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary) + ) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("usageRow-\(rule.name)") + } + // MARK: - Rules @ViewBuilder @@ -147,7 +201,7 @@ struct AppsHomeView: View { } private func ruleRow(for rule: BlockingRule, now: Date) -> some View { - let status = rule.status(at: now) + let status = liveStatus(for: rule, now: now) return Button { detailRule = rule } label: { @@ -179,11 +233,15 @@ struct AppsHomeView: View { private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String { switch rule.kind { case .schedule: - status.label(relativeTo: now) + return status.label(relativeTo: now) case .timeLimit: - rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled" + // 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: - rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled" + if case .upcoming = status { return "\(rule.maxOpens) opens / day" } + return status.label(relativeTo: now) } } diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift index ee43b15..2ed5e99 100644 --- a/OpenAppLock/Views/Rules/RuleDetailSheet.swift +++ b/OpenAppLock/Views/Rules/RuleDetailSheet.swift @@ -14,6 +14,7 @@ struct RuleDetailSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext + @Environment(RuleEnforcer.self) private var enforcer @State private var isEditing = false @State private var pendingDeletion = false @@ -50,13 +51,14 @@ struct RuleDetailSheet: View { } private func detailList(now: Date) -> some View { - let status = rule.status(at: now) + let usage = enforcer.usage(for: rule, at: now) + let status = rule.status(at: now, usage: usage) return List { Section { detailRows } Section { - if RulePolicy.canEdit(rule, at: now) { + if RulePolicy.canEdit(rule, usage: usage, at: now) { Button { isEditing = true } label: { diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift new file mode 100644 index 0000000..4df2f84 --- /dev/null +++ b/OpenAppLockTests/UsageTests.swift @@ -0,0 +1,222 @@ +// +// UsageTests.swift +// OpenAppLockTests +// + +import Foundation +import Testing + +@testable import OpenAppLock + +@MainActor +@Suite("Usage ledger") +struct UsageLedgerTests { + private func makeLedger() -> UsageLedger { + let suiteName = "usage-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return UsageLedger(defaults: defaults) + } + + let monday = date(2025, 1, 6, 10, 0) + let tuesday = date(2025, 1, 7, 10, 0) + + @Test("Day keys are calendar dates") + func dayKey() { + #expect(UsageLedger.dayKey(for: monday, calendar: utc) == "2025-01-06") + #expect(UsageLedger.dayKey(for: date(2025, 12, 31, 23, 59), calendar: utc) == "2025-12-31") + } + + @Test("Usage defaults to zero and round-trips") + func roundTrip() { + let ledger = makeLedger() + let ruleID = UUID() + #expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc) == RuleUsage()) + + ledger.setUsage( + RuleUsage(minutesUsed: 12, opensUsed: 3), + for: ruleID, onDayContaining: monday, calendar: utc + ) + let read = ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc) + #expect(read.minutesUsed == 12) + #expect(read.opensUsed == 3) + } + + @Test("Recorded minutes are monotonic per day") + func monotonicMinutes() { + let ledger = makeLedger() + let ruleID = UUID() + ledger.recordMinutesUsed(10, for: ruleID, onDayContaining: monday, calendar: utc) + ledger.recordMinutesUsed(7, for: ruleID, onDayContaining: monday, calendar: utc) + #expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).minutesUsed == 10) + ledger.recordMinutesUsed(25, for: ruleID, onDayContaining: monday, calendar: utc) + #expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).minutesUsed == 25) + } + + @Test("Opens increment per day") + func opensIncrement() { + let ledger = makeLedger() + let ruleID = UUID() + ledger.recordOpen(for: ruleID, onDayContaining: monday, calendar: utc) + ledger.recordOpen(for: ruleID, onDayContaining: monday, calendar: utc) + #expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).opensUsed == 2) + } + + @Test("Usage is separated by day and rule") + func separation() { + let ledger = makeLedger() + let first = UUID() + let second = UUID() + ledger.recordMinutesUsed(30, for: first, onDayContaining: monday, calendar: utc) + #expect(ledger.usage(for: first, onDayContaining: tuesday, calendar: utc) == RuleUsage()) + #expect(ledger.usage(for: second, onDayContaining: monday, calendar: utc) == RuleUsage()) + } +} + +@MainActor +@Suite("Usage-aware rule status") +struct UsageStatusTests { + let mondayMorning = date(2025, 1, 6, 10, 0) + + private func timeLimitRule(limit: Int = 45) -> BlockingRule { + BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, + dailyLimitMinutes: limit) + } + + private func openLimitRule(maxOpens: Int = 5) -> BlockingRule { + BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, + maxOpens: maxOpens) + } + + @Test("A time-limit rule with budget left is not active") + func budgetLeftNotActive() { + let rule = timeLimitRule() + let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 20)) + #expect(!status.isActive) + } + + @Test("A time-limit rule blocks until midnight once its budget is spent") + func spentBudgetBlocksUntilMidnight() { + let rule = timeLimitRule(limit: 45) + let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 45)) + #expect(status == .active(until: date(2025, 1, 7, 0, 0))) + } + + @Test("An open-limit rule blocks once opens are exhausted") + func exhaustedOpensBlock() { + let rule = openLimitRule(maxOpens: 5) + let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(opensUsed: 5)) + #expect(status == .active(until: date(2025, 1, 7, 0, 0))) + } + + @Test("A spent budget on a disabled day does not block") + func disabledDayDoesNotBlock() { + let rule = timeLimitRule() + rule.days = Weekday.weekends + let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 99)) + #expect(!status.isActive) + } + + @Test("Unblocking a limit-blocked rule pauses it until midnight") + func unblockPausesUntilMidnight() { + let rule = timeLimitRule(limit: 45) + let usage = RuleUsage(minutesUsed: 45) + #expect(RulePolicy.canUnblock(rule, usage: usage, at: mondayMorning, calendar: utc)) + #expect(RulePolicy.unblock(rule, usage: usage, at: mondayMorning, calendar: utc)) + #expect(rule.pausedUntil == date(2025, 1, 7, 0, 0)) + #expect( + rule.status(at: mondayMorning, calendar: utc, usage: usage) + == .paused(until: date(2025, 1, 7, 0, 0))) + } + + @Test("Hard Mode locks a limit-blocked rule and its app lists") + func hardModeLocksLimitBlock() { + let rule = timeLimitRule(limit: 45) + rule.hardMode = true + let usage = RuleUsage(minutesUsed: 45) + #expect(RulePolicy.isHardLocked(rule, usage: usage, at: mondayMorning, calendar: utc)) + #expect(!RulePolicy.canUnblock(rule, usage: usage, at: mondayMorning, calendar: utc)) + #expect( + !RulePolicy.canEditAppLists( + rules: [rule], usageFor: { _ in usage }, at: mondayMorning, calendar: utc)) + } +} + +@MainActor +@Suite("Enforcement of limit rules") +struct UsageEnforcementTests { + let mondayMorning = date(2025, 1, 6, 10, 0) + + @Test("A spent time-limit rule is shielded in Block mode") + func shieldsSpentTimeLimit() { + let shields = MockShieldController() + let ledger = MockUsageLedger() + let enforcer = RuleEnforcer(shields: shields, usage: ledger) + let rule = BlockingRule( + name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45) + ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45) + + enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + + #expect(shields.shieldedRuleIDs == [rule.id]) + #expect(shields.appliedModes[rule.id] == .block) + } + + @Test("A limit rule with budget left is not shielded") + func leavesUnspentLimitAlone() { + let shields = MockShieldController() + let ledger = MockUsageLedger() + let enforcer = RuleEnforcer(shields: shields, usage: ledger) + let rule = BlockingRule( + name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5) + ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2) + + enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + + #expect(shields.shieldedRuleIDs.isEmpty) + } +} + +@MainActor +@Suite("Usage display strings") +struct UsageDisplayTests { + let timeRule = BlockingRule( + name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45) + let openRule = BlockingRule( + name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5) + + @Test("Time-limit rows show minutes used and remaining") + func timeLimitStrings() { + let usage = RuleUsage(minutesUsed: 18) + #expect(UsageDisplay.subtitle(for: timeRule, usage: usage) == "18m of 45m used today") + #expect(UsageDisplay.remainingLabel(for: timeRule, usage: usage, isPaused: false) == "27m left") + } + + @Test("Open-limit rows show opens used and remaining") + func openLimitStrings() { + let usage = RuleUsage(opensUsed: 2) + #expect(UsageDisplay.subtitle(for: openRule, usage: usage) == "2 of 5 opens today") + #expect(UsageDisplay.remainingLabel(for: openRule, usage: usage, isPaused: false) == "3 opens left") + + let oneLeft = RuleUsage(opensUsed: 4) + #expect(UsageDisplay.remainingLabel(for: openRule, usage: oneLeft, isPaused: false) == "1 open left") + } + + @Test("Spent budgets read as blocked, or unblocked while paused") + func exhaustedStrings() { + let spent = RuleUsage(minutesUsed: 45) + #expect( + UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: false) + == "Blocked until tomorrow") + #expect( + UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: true) + == "Unblocked until tomorrow") + #expect(UsageDisplay.subtitle(for: timeRule, usage: spent) == "45m of 45m used today") + } + + @Test("Overshoot clamps to the budget") + func overshootClamps() { + let over = RuleUsage(minutesUsed: 60) + #expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today") + } +} diff --git a/OpenAppLockUITests/UsageUITests.swift b/OpenAppLockUITests/UsageUITests.swift new file mode 100644 index 0000000..70bac34 --- /dev/null +++ b/OpenAppLockUITests/UsageUITests.swift @@ -0,0 +1,49 @@ +// +// UsageUITests.swift +// OpenAppLockUITests +// + +import XCTest + +/// The Usage section under Blocked Apps — seeded with limit rules at various +/// budget states ("Time Keeper" 18m/45m, "Gate Keeper" 2/5 opens, +/// "Doom Scroll" spent → blocked). +final class UsageUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testUsageSectionShowsTimeAndOpenBudgets() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits") + + XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists) + + let timeRow = app.element("usageRow-Time Keeper").waitToAppear() + XCTAssertTrue(timeRow.label.contains("18m of 45m used today"), "Got: \(timeRow.label)") + XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)") + + let openRow = app.element("usageRow-Gate Keeper").waitToAppear() + XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)") + XCTAssertTrue(openRow.label.contains("3 opens left"), "Got: \(openRow.label)") + } + + func testSpentBudgetShowsAsBlockedUntilTomorrow() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits") + + let spentRow = app.element("usageRow-Doom Scroll").waitToAppear() + XCTAssertTrue(spentRow.label.contains("Blocked until tomorrow"), "Got: \(spentRow.label)") + // A spent budget is a real block: the rule surfaces in Blocked Apps. + XCTAssertTrue(app.buttons["blockedTile-Doom Scroll"].waitToAppear().exists) + } + + func testSpentBudgetCanBeUnblockedUntilTomorrow() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits") + + app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap() + app.sheets.buttons["Unblock"].waitToAppear().tap() + + app.staticTexts["nothingBlockedLabel"].waitToAppear() + let row = app.element("usageRow-Doom Scroll").waitToAppear() + XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)") + } +}