feat: track limit budgets in a usage ledger and surface them in a Usage section

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:22:48 -04:00
parent c7f2979d8e
commit df6b7b689d
14 changed files with 640 additions and 62 deletions

View File

@@ -8,46 +8,55 @@ import Foundation
/// Gates every mutation of a rule. This is where Hard Mode is enforced: /// 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 /// while a hard-mode rule is actively blocking, nothing about it can be
/// weakened until the window ends. /// 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 { enum RulePolicy {
/// True while the rule is actively blocking with Hard Mode on. /// True while the rule is actively blocking with Hard Mode on.
static func isHardLocked( 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 { ) -> Bool {
rule.hardMode && rule.status(at: now, calendar: calendar).isActive rule.hardMode && rule.status(at: now, calendar: calendar, usage: usage).isActive
} }
static func canEdit( 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 { ) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar) !isHardLocked(rule, usage: usage, at: now, calendar: calendar)
} }
static func canDisable( 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 { ) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar) !isHardLocked(rule, usage: usage, at: now, calendar: calendar)
} }
static func canDelete( 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 { ) -> 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"). /// 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( 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 { ) -> 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 /// 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. /// actively blocking that is the whole point of a hard block.
static func canTurnOffHardMode( 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 { ) -> 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 /// 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 /// back door out of the hard block. Creating new lists and picking lists
/// for other rules stay allowed; they cannot weaken an active block. /// for other rules stay allowed; they cannot weaken an active block.
static func canEditAppLists( 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 { ) -> 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) /// Pauses the rule's current block. Returns false (and changes nothing)
/// when unblocking is not allowed. /// 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 @discardableResult
static func unblock( 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 { ) -> Bool {
guard canUnblock(rule, at: now, calendar: calendar), guard canUnblock(rule, usage: usage, at: now, calendar: calendar) else { return false }
let window = rule.schedule.activeWindow(containing: now, calendar: calendar) switch rule.kind {
case .schedule:
guard let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
else { return false } else { return false }
rule.pausedUntil = window.end rule.pausedUntil = window.end
case .timeLimit, .openLimit:
guard let midnight = calendar.nextMidnight(after: now) else { return false }
rule.pausedUntil = midnight
}
return true return true
} }
} }

View File

@@ -45,14 +45,25 @@ enum RuleStatus: Equatable, Sendable {
} }
extension BlockingRule { extension BlockingRule {
/// Live status of this rule. Schedule rules derive it from their time window; /// Live status of this rule. Schedule rules derive it from their time
/// time/open-limit rules report upcoming based on their enabled days only /// window. Time/open-limit rules derive it from the day's usage: once the
/// (their blocking is triggered by usage, not the clock). /// budget is spent on an enabled day they are active (blocking) until the
func status(at now: Date = .now, calendar: Calendar = .current) -> RuleStatus { /// 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 isEnabled else { return .disabled }
guard !days.isEmpty else { return .dormant } guard !days.isEmpty else { return .dormant }
guard kind == .schedule else { 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 { guard let next = schedule.nextStart(after: now, calendar: calendar) else {
return .dormant return .dormant
} }
@@ -70,4 +81,30 @@ extension BlockingRule {
} }
return .dormant 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))
}
} }

View File

@@ -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"
}
}
}

View File

@@ -31,8 +31,12 @@ struct OpenAppLockApp: App {
fatalError("Could not create ModelContainer: \(error)") fatalError("Could not create ModelContainer: \(error)")
} }
let usageLedger: UsageReading = config.isUITesting ? MockUsageLedger() : UsageLedger()
if let scenario = config.seedScenario { 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) AppListMigration.run(in: container.mainContext)
@@ -46,7 +50,7 @@ struct OpenAppLockApp: App {
let shields: ShieldApplying = let shields: ShieldApplying =
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController() config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
_enforcer = State(initialValue: RuleEnforcer(shields: shields)) _enforcer = State(initialValue: RuleEnforcer(shields: shields, usage: usageLedger))
} }
var body: some Scene { var body: some Scene {

View File

@@ -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
}
}

View File

@@ -13,6 +13,10 @@ struct LaunchConfiguration: Equatable {
case standard case standard
/// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule. /// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule.
case hardModeActive = "hard-mode-active" 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 var isUITesting = false

View File

@@ -7,18 +7,32 @@ import Foundation
import Observation import Observation
/// Turns the current set of rules into shield state: schedule rules with an /// 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 /// Background transitions (and usage tracking itself) belong to the
/// react to usage thresholds in the background; they are persisted and shown /// DeviceActivity monitor extension; this keeps shields correct while the
/// in the UI but not yet enforced here. /// app runs.
@Observable @Observable
final class RuleEnforcer { final class RuleEnforcer {
private(set) var blockingRuleIDs: Set<UUID> = [] private(set) var blockingRuleIDs: Set<UUID> = []
private let shields: ShieldApplying 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.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, /// 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 { if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil rule.pausedUntil = nil
} }
guard rule.kind == .schedule, let usage = usage(for: rule, at: now, calendar: calendar)
rule.status(at: now, calendar: calendar).isActive guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue }
else { continue }
active.insert(rule.id) active.insert(rule.id)
shields.applyShield( shields.applyShield(
ruleID: rule.id, ruleID: rule.id,
selectionData: rule.appList?.selectionData, 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 blockAdultContent: rule.blockAdultContent
) )
} }

View File

@@ -12,6 +12,7 @@ enum SampleRules {
static func seed( static func seed(
_ scenario: LaunchConfiguration.SeedScenario, _ scenario: LaunchConfiguration.SeedScenario,
into context: ModelContext, into context: ModelContext,
usage: MockUsageLedger? = nil,
now: Date = .now, now: Date = .now,
calendar: Calendar = .current calendar: Calendar = .current
) { ) {
@@ -20,18 +21,31 @@ enum SampleRules {
let distractions = AppList(name: "Distractions", selectionCount: 3) let distractions = AppList(name: "Distractions", selectionCount: 3)
context.insert(distractions) context.insert(distractions)
let rules = let rules: [BlockingRule]
switch scenario { switch scenario {
case .standard: case .standard:
[ rules = [
activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar), activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar),
upcomingRule(named: "Sleep", now: now, calendar: calendar), upcomingRule(named: "Sleep", now: now, calendar: calendar),
] ]
case .hardModeActive: case .hardModeActive:
[ rules = [
activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar), activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar),
upcomingRule(named: "Sleep", 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 // Relationships are wired only after both sides are managed
// (see BlockingRule.appList). // (see BlockingRule.appList).

View File

@@ -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()
}
}

View File

@@ -15,6 +15,7 @@ struct AppListPickerSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \AppList.createdAt) private var lists: [AppList] @Query(sort: \AppList.createdAt) private var lists: [AppList]
@Query private var rules: [BlockingRule] @Query private var rules: [BlockingRule]
@@ -22,10 +23,11 @@ struct AppListPickerSheet: View {
@State private var creatingList = false @State private var creatingList = false
@State private var deletionBlocked = false @State private var deletionBlocked = false
/// While any hard-mode rule is actively blocking, lists are read-only /// While any hard-mode rule is actively blocking (by the clock or a spent
/// editing one would be a back door out of the hard block. /// limit budget), lists are read-only editing one would be a back door
/// out of the hard block.
private var listsLocked: Bool { private var listsLocked: Bool {
!RulePolicy.canEditAppLists(rules: rules) !RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) })
} }
var body: some View { var body: some View {

View File

@@ -50,7 +50,7 @@ struct AppsHomeView: View {
) { ) {
Button("Unblock", role: .destructive) { Button("Unblock", role: .destructive) {
if let rule = unblockCandidate { if let rule = unblockCandidate {
RulePolicy.unblock(rule) RulePolicy.unblock(rule, usage: enforcer.usage(for: rule))
refreshEnforcement() refreshEnforcement()
} }
unblockCandidate = nil unblockCandidate = nil
@@ -74,15 +74,22 @@ struct AppsHomeView: View {
private func ruleList(now: Date) -> some View { private func ruleList(now: Date) -> some View {
List { List {
blockedSection(now: now) blockedSection(now: now)
usageSection(now: now)
rulesSection(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 // MARK: - Blocked Apps
@ViewBuilder @ViewBuilder
private func blockedSection(now: Date) -> some View { 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 { Section {
if blocking.isEmpty { if blocking.isEmpty {
Text("Nothing is blocked right now.") Text("Nothing is blocked right now.")
@@ -100,7 +107,7 @@ struct AppsHomeView: View {
private func blockedRow(for rule: BlockingRule, now: Date) -> some View { private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
Button { Button {
if RulePolicy.canUnblock(rule, at: now) { if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
unblockCandidate = rule unblockCandidate = rule
} else { } else {
hardModeBlockedAttempt = true hardModeBlockedAttempt = true
@@ -120,6 +127,53 @@ struct AppsHomeView: View {
.accessibilityIdentifier("blockedTile-\(rule.name)") .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 // MARK: - Rules
@ViewBuilder @ViewBuilder
@@ -147,7 +201,7 @@ struct AppsHomeView: View {
} }
private func ruleRow(for rule: BlockingRule, now: Date) -> some 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 { return Button {
detailRule = rule detailRule = rule
} label: { } label: {
@@ -179,11 +233,15 @@ 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 { switch rule.kind {
case .schedule: case .schedule:
status.label(relativeTo: now) return status.label(relativeTo: now)
case .timeLimit: 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: case .openLimit:
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled" if case .upcoming = status { return "\(rule.maxOpens) opens / day" }
return status.label(relativeTo: now)
} }
} }

View File

@@ -14,6 +14,7 @@ struct RuleDetailSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@State private var isEditing = false @State private var isEditing = false
@State private var pendingDeletion = false @State private var pendingDeletion = false
@@ -50,13 +51,14 @@ struct RuleDetailSheet: View {
} }
private func detailList(now: Date) -> some 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 { return List {
Section { Section {
detailRows detailRows
} }
Section { Section {
if RulePolicy.canEdit(rule, at: now) { if RulePolicy.canEdit(rule, usage: usage, at: now) {
Button { Button {
isEditing = true isEditing = true
} label: { } label: {

View File

@@ -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")
}
}

View File

@@ -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)")
}
}