From 410bb6cf0df2ed7145152166595bf161635a384b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 15 Jun 2026 17:10:58 -0400 Subject: [PATCH 1/2] refactor: unify home & rules row labels across rule types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render every Home and Rules row with one shape — leading kind icon, name, and a context subtitle — and drop the trailing status string so the two tabs (and the three rule kinds) read consistently. - Home subtitle is " · "; the Rules tab omits the type since its section header conveys it. Blocking schedule rows now show a countdown ("Schedule · 6h left"); limit rows show adaptive context — live usage once used today ("18m of 45m used today"), else the daily budget ("45m / day"). - Add the leading kind icon to Home rows (reverts the earlier icon removal). - Replace BlockingRule.statusLabel with a single rowContext source of truth; restructure UsageDisplay (usagePhrase / budgetPhrase / homeSubtitle; drop typedSubtitle / remainingLabel). The Rules ruleStatus- id moves onto the context subtitle. - Update Docs/AGENT_RULES_FEATURE_SPEC.md §6 and the affected unit/UI tests (219 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/AGENT_RULES_FEATURE_SPEC.md | 4 +- OpenAppLock/Logic/RuleStatus.swift | 36 ++++++++---- OpenAppLock/Logic/UsageDisplay.swift | 40 ++++++------- OpenAppLock/Views/Home/HomeView.swift | 41 +++++++------ OpenAppLock/Views/Rules/RuleDetailSheet.swift | 2 +- OpenAppLock/Views/Rules/RulesListView.swift | 17 +++--- OpenAppLockTests/RuleStatusTests.swift | 26 +++++---- OpenAppLockTests/UsageTests.swift | 58 ++++++++++++------- OpenAppLockUITests/UsageUITests.swift | 6 +- 9 files changed, 127 insertions(+), 103 deletions(-) diff --git a/Docs/AGENT_RULES_FEATURE_SPEC.md b/Docs/AGENT_RULES_FEATURE_SPEC.md index 33c1e25..f298956 100644 --- a/Docs/AGENT_RULES_FEATURE_SPEC.md +++ b/Docs/AGENT_RULES_FEATURE_SPEC.md @@ -532,8 +532,8 @@ it runs regardless of the selected tab. | Spec element | Native presentation | |---|---| -| Home tab | `NavigationStack` + `List`. **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: **no leading icon**; a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage) with a ` · ` subtitle. **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking, each row a ` · NN of MM used today` subtitle + trailing remaining/blocked label. | -| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, block summary, trailing live status — green when active); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. | +| Home tab | `NavigationStack` + `List`. Every row carries a **leading kind icon**, the name, and a ` · ` subtitle, where *context* is the rule's live status: a schedule reads its countdown (`Schedule · 6h left`), a limit reads its usage once used today (`Time Limit · 18m of 45m used today`) or its plain budget while untouched (`Time Limit · 45m / day`). **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage). **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking; rows have **no trailing label** (the context lives in the subtitle). | +| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, and a `` subtitle — the same live status/countdown/usage as Home, but **without the type prefix** since the section header already conveys the kind, and **without a separate trailing status label**; the `ruleStatus-` identifier lives on this subtitle); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. | | Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). | | Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead | | New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` | diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index a39db57..f4840f3 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -82,20 +82,32 @@ 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 configuration { - case .schedule: break - case .timeLimit(let config): return "\(config.dailyLimitMinutes)m / day" - case .openLimit(let config): return "\(config.maxOpens) opens / day" + /// The live "context" line shown under a rule's name on the Home and Rules + /// lists, and as the rule-detail caption. A single source of truth so every + /// screen renders a given kind/state the same way. + /// + /// - Schedule rules read their clock status: "6h left", "Starts in 22h", + /// "Paused", "Disabled", "No days selected". + /// - Limit rules share that wording while disabled / dormant / paused; + /// otherwise they read their budget — live usage once the rule has been + /// used today ("18m of 45m used today"), and the plain daily allowance + /// while still untouched ("45m / day"). A spent limit therefore reads + /// "45m of 45m used today", never a clock countdown. + func rowContext(for status: RuleStatus, usage: RuleUsage, relativeTo now: Date) -> String { + switch configuration { + case .schedule: + return status.label(relativeTo: now) + case .timeLimit, .openLimit: + switch status { + case .disabled, .dormant, .paused: + return status.label(relativeTo: now) + case .active, .upcoming: + let usedToday = usage.minutesUsed > 0 || usage.opensUsed > 0 + return usedToday + ? UsageDisplay.usagePhrase(for: self, usage: usage) + : UsageDisplay.budgetPhrase(for: self) } } - return status.label(relativeTo: now) } /// Whether the rule's enabled days include the day containing `now`. diff --git a/OpenAppLock/Logic/UsageDisplay.swift b/OpenAppLock/Logic/UsageDisplay.swift index ed8cf28..7197001 100644 --- a/OpenAppLock/Logic/UsageDisplay.swift +++ b/OpenAppLock/Logic/UsageDisplay.swift @@ -5,20 +5,22 @@ 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". +/// Strings for the home- and rules-list rows. Used values clamp to the budget +/// so overshoot (thresholds can fire late) never reads "50m of 45m". enum UsageDisplay { - /// The usage subtitle prefixed with the rule's type, so the kind is clear - /// without relying on an icon: "Time Limit · 18m of 45m used today". - /// Schedule rules (no usage text) fall back to just the type name. - static func typedSubtitle(for rule: BlockingRule, usage: RuleUsage) -> String { - let usageText = subtitle(for: rule, usage: usage) - guard !usageText.isEmpty else { return rule.kind.displayName } - return "\(rule.kind.displayName) · \(usageText)" + /// The Home-list subtitle: the rule's type, then its live context, so the + /// kind reads without relying on the icon ("Time Limit · 18m of 45m used + /// today", "Schedule · 6h left"). The Rules list omits the type prefix + /// because its section header already conveys it. + static func homeSubtitle( + for rule: BlockingRule, status: RuleStatus, usage: RuleUsage, relativeTo now: Date + ) -> String { + "\(rule.kind.displayName) · \(rule.rowContext(for: status, usage: usage, relativeTo: now))" } - /// "18m of 45m used today" / "2 of 5 opens today". - static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String { + /// "18m of 45m used today" / "2 of 5 opens today". Empty for schedule rules, + /// which have no usage budget. + static func usagePhrase(for rule: BlockingRule, usage: RuleUsage) -> String { switch rule.configuration { case .schedule: "" @@ -30,20 +32,16 @@ enum UsageDisplay { } } - /// "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" - } + /// "45m / day" / "5 opens / day" — the plain daily allowance, shown while a + /// limit rule has no usage recorded today. Empty for schedule rules. + static func budgetPhrase(for rule: BlockingRule) -> String { switch rule.configuration { case .schedule: - return "" + "" case .timeLimit(let config): - return "\(config.dailyLimitMinutes - usage.minutesUsed)m left" + "\(config.dailyLimitMinutes)m / day" case .openLimit(let config): - let remaining = config.maxOpens - usage.opensUsed - return remaining == 1 ? "1 open left" : "\(remaining) opens left" + "\(config.maxOpens) opens / day" } } } diff --git a/OpenAppLock/Views/Home/HomeView.swift b/OpenAppLock/Views/Home/HomeView.swift index 6fe74c7..7fd95b1 100644 --- a/OpenAppLock/Views/Home/HomeView.swift +++ b/OpenAppLock/Views/Home/HomeView.swift @@ -80,12 +80,13 @@ struct HomeView: View { } } - /// A blocking rule: no leading icon. A limit rule shows its type + usage so - /// the kind reads without an icon; a schedule rule shows just its name. - /// Trailing affordance: a lock when Hard Mode (the block can't be lifted), - /// otherwise an Unblock button. + /// A blocking rule: leading kind icon, name, and a " · " + /// subtitle (a schedule shows its countdown, a limit its usage). Trailing + /// affordance: a lock when Hard Mode (the block can't be lifted), otherwise + /// an Unblock button. private func blockingRow(for rule: BlockingRule, now: Date) -> some View { let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage() + let status = liveStatus(for: rule, now: now) return Button { if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) { unblockCandidate = rule @@ -94,14 +95,13 @@ struct HomeView: View { } } label: { HStack { + kindIcon(for: rule) VStack(alignment: .leading, spacing: 2) { Text(rule.name) .foregroundStyle(Color.primary) - if rule.kind != .schedule { - Text(UsageDisplay.typedSubtitle(for: rule, usage: usage)) - .font(.caption) - .foregroundStyle(Color.secondary) - } + Text(UsageDisplay.homeSubtitle(for: rule, status: status, usage: usage, relativeTo: now)) + .font(.caption) + .foregroundStyle(Color.secondary) } Spacer() if rule.hardMode { @@ -116,12 +116,21 @@ struct HomeView: View { .accessibilityIdentifier("blockedTile-\(rule.name)") } + /// The rule's kind icon, tinted, sized to align row text. Decorative — the + /// type is also spelled out in the subtitle, so it is hidden from VoiceOver. + private func kindIcon(for rule: BlockingRule) -> some View { + Image(systemName: rule.kind.symbolName) + .foregroundStyle(.tint) + .frame(width: 28) + .accessibilityHidden(true) + } + // MARK: - Usage /// Live tracking for every limit rule scheduled today that is *not* already /// blocking. Once a budget is spent (the rule is actively blocking) the row /// moves up to "Currently Blocking"; a soft-unblocked rule (paused) stays - /// here reading "Unblocked until tomorrow". + /// here reading "Paused". @ViewBuilder private func usageSection(now: Date) -> some View { let tracked = rules.filter { @@ -141,23 +150,17 @@ struct HomeView: View { 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 } + let status = liveStatus(for: rule, now: now) return HStack { + kindIcon(for: rule) VStack(alignment: .leading, spacing: 2) { Text(rule.name) .foregroundStyle(Color.primary) - Text(UsageDisplay.typedSubtitle(for: rule, usage: usage)) + Text(UsageDisplay.homeSubtitle(for: rule, status: status, usage: usage, relativeTo: now)) .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)") diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift index 7c191da..80f8323 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), \(rule.statusLabel(for: status, relativeTo: now))") + Text("\(rule.kind.displayName), \(rule.rowContext(for: status, usage: usage ?? RuleUsage(), relativeTo: now))") .font(.caption) .foregroundStyle(.secondary) .accessibilityIdentifier("detailStatusLabel") diff --git a/OpenAppLock/Views/Rules/RulesListView.swift b/OpenAppLock/Views/Rules/RulesListView.swift index efab762..459f936 100644 --- a/OpenAppLock/Views/Rules/RulesListView.swift +++ b/OpenAppLock/Views/Rules/RulesListView.swift @@ -78,7 +78,8 @@ struct RulesListView: View { } private func ruleRow(for rule: BlockingRule, now: Date) -> some View { - let status = rule.status(at: now, usage: enforcer.usage(for: rule, at: now)) + let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage() + let status = rule.status(at: now, usage: usage) return Button { detailRule = rule } label: { @@ -86,24 +87,20 @@ struct RulesListView: View { Image(systemName: rule.kind.symbolName) .foregroundStyle(.tint) .frame(width: 28) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(rule.name) .foregroundStyle(Color.primary) - Text(blockSummary(for: rule)) + // The kind is conveyed by the section header, so the + // subtitle is just the live context (no type prefix). + Text(rule.rowContext(for: status, usage: usage, relativeTo: now)) .font(.caption) .foregroundStyle(Color.secondary) + .accessibilityIdentifier("ruleStatus-\(rule.name)") } Spacer() - Text(rule.statusLabel(for: status, relativeTo: now)) - .font(.subheadline) - .foregroundStyle(status.isActive ? .green : .secondary) - .accessibilityIdentifier("ruleStatus-\(rule.name)") } } .accessibilityIdentifier("ruleCard-\(rule.name)") } - - private func blockSummary(for rule: BlockingRule) -> String { - "\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")" - } } diff --git a/OpenAppLockTests/RuleStatusTests.swift b/OpenAppLockTests/RuleStatusTests.swift index d755298..dc84eab 100644 --- a/OpenAppLockTests/RuleStatusTests.swift +++ b/OpenAppLockTests/RuleStatusTests.swift @@ -110,26 +110,26 @@ struct RuleStatusTests { #expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused") } - // MARK: - Kind-aware display label + // MARK: - Kind-aware row context - /// 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") + /// An untouched time-limit rule has no clock window, so it shows its daily + /// budget — never the vestigial 09:00 start as "Starts in 22h". + @Test("Untouched time-limit rule shows its daily budget, not a clock countdown") func timeLimitDisplayLabel() { let rule = BlockingRule( name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(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") + #expect(rule.rowContext(for: status, usage: RuleUsage(), relativeTo: now) == "15m / day") } - @Test("Idle open-limit rule shows its daily opens budget") + @Test("Untouched open-limit rule shows its daily opens budget") func openLimitDisplayLabel() { let rule = BlockingRule( name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(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") + #expect(rule.rowContext(for: status, usage: RuleUsage(), relativeTo: now) == "5 opens / day") } @Test("Schedule rule still shows the clock countdown") @@ -137,17 +137,19 @@ struct RuleStatusTests { 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") + #expect(weekend.rowContext(for: status, usage: RuleUsage(), relativeTo: friday) == "Starts in 22h") } - @Test("A spent time-limit budget still shows the blocked countdown") + /// Limit rules block by budget, not by the clock, so a spent one reads its + /// usage ("15m of 15m used today"), never a countdown (that is schedule-only). + @Test("A spent time-limit budget shows its usage, not a countdown") func timeLimitBlockingDisplayLabel() { let rule = BlockingRule( name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 15))) let now = date(2025, 1, 6, 11, 38) - let status = rule.status(at: now, calendar: utc, usage: RuleUsage(minutesUsed: 15)) + let usage = RuleUsage(minutesUsed: 15) + let status = rule.status(at: now, calendar: utc, usage: usage) #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)) + #expect(rule.rowContext(for: status, usage: usage, relativeTo: now) == "15m of 15m used today") } } diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index fb687fd..770e04e 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -220,48 +220,62 @@ struct UsageDisplayTests { configuration: .openLimit(OpenLimitConfig(maxOpens: 5)), days: Weekday.everyDay) - @Test("Time-limit rows show minutes used and remaining") + let now = date(2025, 1, 6, 10, 0) // a Monday, so the every-day rules fire + + @Test("Time-limit rows show minutes used of the budget") 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") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage) == "18m of 45m used today") } - @Test("Open-limit rows show opens used and remaining") + @Test("Open-limit rows show opens used of the budget") 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") + #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage) == "2 of 5 opens today") + } - let oneLeft = RuleUsage(opensUsed: 4) - #expect(UsageDisplay.remainingLabel(for: openRule, usage: oneLeft, isPaused: false) == "1 open left") + /// Limit context adapts: the daily budget while untouched, live usage once + /// the rule has been used today. + @Test("Limit context shows budget while idle and usage once used") + func adaptiveLimitContext() { + let idle = timeRule.status(at: now, calendar: utc, usage: RuleUsage()) + #expect(timeRule.rowContext(for: idle, usage: RuleUsage(), relativeTo: now) == "45m / day") + + let used = RuleUsage(minutesUsed: 18) + let active = timeRule.status(at: now, calendar: utc, usage: used) + #expect(timeRule.rowContext(for: active, usage: used, relativeTo: now) == "18m of 45m used today") } - @Test("Spent budgets read as blocked, or unblocked while paused") - func exhaustedStrings() { + @Test("A spent limit reads its usage; unblocking it reads Paused") + func exhaustedContext() { 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") + let blocking = timeRule.status(at: now, calendar: utc, usage: spent) + #expect(blocking.isActive) + #expect(timeRule.rowContext(for: blocking, usage: spent, relativeTo: now) == "45m of 45m used today") + + timeRule.pausedUntil = utc.date(byAdding: .hour, value: 5, to: now) + let paused = timeRule.status(at: now, calendar: utc, usage: spent) + #expect(timeRule.rowContext(for: paused, usage: spent, relativeTo: now) == "Paused") } @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") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over) == "45m of 45m used today") } - @Test("Typed subtitles prefix the rule kind so type is clear without the icon") - func typedSubtitles() { + @Test("Home subtitles prefix the rule kind so the type reads without the icon") + func homeSubtitles() { + let timeUsage = RuleUsage(minutesUsed: 18) + let timeStatus = timeRule.status(at: now, calendar: utc, usage: timeUsage) #expect( - UsageDisplay.typedSubtitle(for: timeRule, usage: RuleUsage(minutesUsed: 18)) + UsageDisplay.homeSubtitle(for: timeRule, status: timeStatus, usage: timeUsage, relativeTo: now) == "Time Limit · 18m of 45m used today") + + let openUsage = RuleUsage(opensUsed: 2) + let openStatus = openRule.status(at: now, calendar: utc, usage: openUsage) #expect( - UsageDisplay.typedSubtitle(for: openRule, usage: RuleUsage(opensUsed: 2)) + UsageDisplay.homeSubtitle(for: openRule, status: openStatus, usage: openUsage, relativeTo: now) == "Open Limit · 2 of 5 opens today") } } diff --git a/OpenAppLockUITests/UsageUITests.swift b/OpenAppLockUITests/UsageUITests.swift index 20c4a86..b18e7a8 100644 --- a/OpenAppLockUITests/UsageUITests.swift +++ b/OpenAppLockUITests/UsageUITests.swift @@ -18,16 +18,14 @@ final class UsageUITests: XCTestCase { XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists) - // The row leads with the rule type, then the live usage and remaining. + // The row leads with the rule type, then the live usage of the budget. let timeRow = app.element("usageRow-Time Keeper").waitToAppear() XCTAssertTrue(timeRow.label.contains("Time Limit"), "Got: \(timeRow.label)") 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("Open Limit"), "Got: \(openRow.label)") XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)") - XCTAssertTrue(openRow.label.contains("3 opens left"), "Got: \(openRow.label)") } func testSpentBudgetMovesToCurrentlyBlocking() throws { @@ -55,6 +53,6 @@ final class UsageUITests: XCTestCase { // Unblocked → paused (not blocking), so it drops back into Usage. app.staticTexts["nothingBlockedLabel"].waitToAppear() let row = app.element("usageRow-Doom Scroll").waitToAppear() - XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)") + XCTAssertTrue(row.label.contains("Paused"), "Got: \(row.label)") } } From aa4f6dbcb77cd0271df72809b6fdd9d79aab9460 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Mon, 15 Jun 2026 17:37:18 -0400 Subject: [PATCH 2/2] refactor: drop redundant "today" from limit usage rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage subtitle covers the current day by definition, so "used today" / "opens today" was redundant. Trim to "30m of 30m used" / "2 of 5 opens" on both the Home and Rules rows. The on-device shield text ("Opened X of N times today") is unchanged — it is a separate surface. Also refreshes the now-stale §3 Usage description in the feature spec (trailing labels were removed and "Unblocked until tomorrow" became "Paused" in the prior commit). Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/AGENT_RULES_FEATURE_SPEC.md | 13 +++++++------ OpenAppLock/Logic/RuleStatus.swift | 6 +++--- OpenAppLock/Logic/UsageDisplay.swift | 14 +++++++------- OpenAppLockTests/RuleStatusTests.swift | 4 ++-- OpenAppLockTests/UsageTests.swift | 14 +++++++------- OpenAppLockUITests/UsageUITests.swift | 6 +++--- 6 files changed, 29 insertions(+), 28 deletions(-) diff --git a/Docs/AGENT_RULES_FEATURE_SPEC.md b/Docs/AGENT_RULES_FEATURE_SPEC.md index f298956..0eed12b 100644 --- a/Docs/AGENT_RULES_FEATURE_SPEC.md +++ b/Docs/AGENT_RULES_FEATURE_SPEC.md @@ -91,14 +91,15 @@ Dark theme throughout (near-black background, very dark green tint). also appear here, blocked until midnight. 3. **Usage** *(OpenAppLock addition)* — a section showing live tracking for every enabled Time/Open Limit rule scheduled today - **that is not currently blocking**. Each row leads its subtitle with the rule - **type** so the kind is clear without relying on the icon: - - Time Limit row: subtitle "Time Limit · 18m of 45m used today", trailing "27m left". - - Open Limit row: subtitle "Open Limit · 2 of 5 opens today", trailing "3 opens left". + **that is not currently blocking**. Each row has a leading kind icon and a + ` · ` subtitle (no trailing label); the type prefix keeps the + kind clear and "today" is dropped as implied: + - Time Limit row: subtitle "Time Limit · 18m of 45m used". + - Open Limit row: subtitle "Open Limit · 2 of 5 opens". A rule whose budget is **spent** (actively blocking) **moves out of Usage into the "Currently Blocking" section** (it shows its type + usage there instead); a *soft-unblocked* spent rule is paused (not blocking), so it returns to Usage - reading "Unblocked until tomorrow". Usage numbers come from the shared app-group + reading "Paused". Usage numbers come from the shared app-group ledger written by the DeviceActivity monitor and shield-action extensions. 3. **Rules** — header row: "Rules ›" (leading, tappable to a full list, presumably) and "**+ New**" (trailing, green tint) which opens the New Rule @@ -532,7 +533,7 @@ it runs regardless of the selected tab. | Spec element | Native presentation | |---|---| -| Home tab | `NavigationStack` + `List`. Every row carries a **leading kind icon**, the name, and a ` · ` subtitle, where *context* is the rule's live status: a schedule reads its countdown (`Schedule · 6h left`), a limit reads its usage once used today (`Time Limit · 18m of 45m used today`) or its plain budget while untouched (`Time Limit · 45m / day`). **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage). **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking; rows have **no trailing label** (the context lives in the subtitle). | +| Home tab | `NavigationStack` + `List`. Every row carries a **leading kind icon**, the name, and a ` · ` subtitle, where *context* is the rule's live status: a schedule reads its countdown (`Schedule · 6h left`), a limit reads its usage once used today (`Time Limit · 18m of 45m used`) or its plain budget while untouched (`Time Limit · 45m / day`). **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage). **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking; rows have **no trailing label** (the context lives in the subtitle). | | Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, and a `` subtitle — the same live status/countdown/usage as Home, but **without the type prefix** since the section header already conveys the kind, and **without a separate trailing status label**; the `ruleStatus-` identifier lives on this subtitle); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. | | Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). | | Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead | diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index f4840f3..5b503b1 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -90,9 +90,9 @@ extension BlockingRule { /// "Paused", "Disabled", "No days selected". /// - Limit rules share that wording while disabled / dormant / paused; /// otherwise they read their budget — live usage once the rule has been - /// used today ("18m of 45m used today"), and the plain daily allowance - /// while still untouched ("45m / day"). A spent limit therefore reads - /// "45m of 45m used today", never a clock countdown. + /// used today ("18m of 45m used"), and the plain daily allowance while + /// still untouched ("45m / day"). A spent limit therefore reads + /// "45m of 45m used", never a clock countdown. func rowContext(for status: RuleStatus, usage: RuleUsage, relativeTo now: Date) -> String { switch configuration { case .schedule: diff --git a/OpenAppLock/Logic/UsageDisplay.swift b/OpenAppLock/Logic/UsageDisplay.swift index 7197001..7b311e4 100644 --- a/OpenAppLock/Logic/UsageDisplay.swift +++ b/OpenAppLock/Logic/UsageDisplay.swift @@ -9,26 +9,26 @@ import Foundation /// so overshoot (thresholds can fire late) never reads "50m of 45m". enum UsageDisplay { /// The Home-list subtitle: the rule's type, then its live context, so the - /// kind reads without relying on the icon ("Time Limit · 18m of 45m used - /// today", "Schedule · 6h left"). The Rules list omits the type prefix - /// because its section header already conveys it. + /// kind reads without relying on the icon ("Time Limit · 18m of 45m used", + /// "Schedule · 6h left"). The Rules list omits the type prefix because its + /// section header already conveys it. static func homeSubtitle( for rule: BlockingRule, status: RuleStatus, usage: RuleUsage, relativeTo now: Date ) -> String { "\(rule.kind.displayName) · \(rule.rowContext(for: status, usage: usage, relativeTo: now))" } - /// "18m of 45m used today" / "2 of 5 opens today". Empty for schedule rules, - /// which have no usage budget. + /// "18m of 45m used" / "2 of 5 opens". Empty for schedule rules, which have + /// no usage budget. ("today" is implied — usage always covers the current day.) static func usagePhrase(for rule: BlockingRule, usage: RuleUsage) -> String { switch rule.configuration { case .schedule: "" case .timeLimit(let config): "\(min(usage.minutesUsed, config.dailyLimitMinutes))m of " - + "\(config.dailyLimitMinutes)m used today" + + "\(config.dailyLimitMinutes)m used" case .openLimit(let config): - "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens today" + "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens" } } diff --git a/OpenAppLockTests/RuleStatusTests.swift b/OpenAppLockTests/RuleStatusTests.swift index dc84eab..9f8ef6b 100644 --- a/OpenAppLockTests/RuleStatusTests.swift +++ b/OpenAppLockTests/RuleStatusTests.swift @@ -141,7 +141,7 @@ struct RuleStatusTests { } /// Limit rules block by budget, not by the clock, so a spent one reads its - /// usage ("15m of 15m used today"), never a countdown (that is schedule-only). + /// usage ("15m of 15m used"), never a countdown (that is schedule-only). @Test("A spent time-limit budget shows its usage, not a countdown") func timeLimitBlockingDisplayLabel() { let rule = BlockingRule( @@ -150,6 +150,6 @@ struct RuleStatusTests { let usage = RuleUsage(minutesUsed: 15) let status = rule.status(at: now, calendar: utc, usage: usage) #expect(status.isActive) - #expect(rule.rowContext(for: status, usage: usage, relativeTo: now) == "15m of 15m used today") + #expect(rule.rowContext(for: status, usage: usage, relativeTo: now) == "15m of 15m used") } } diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 770e04e..4b837ff 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -225,13 +225,13 @@ struct UsageDisplayTests { @Test("Time-limit rows show minutes used of the budget") func timeLimitStrings() { let usage = RuleUsage(minutesUsed: 18) - #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage) == "18m of 45m used today") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage) == "18m of 45m used") } @Test("Open-limit rows show opens used of the budget") func openLimitStrings() { let usage = RuleUsage(opensUsed: 2) - #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage) == "2 of 5 opens today") + #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage) == "2 of 5 opens") } /// Limit context adapts: the daily budget while untouched, live usage once @@ -243,7 +243,7 @@ struct UsageDisplayTests { let used = RuleUsage(minutesUsed: 18) let active = timeRule.status(at: now, calendar: utc, usage: used) - #expect(timeRule.rowContext(for: active, usage: used, relativeTo: now) == "18m of 45m used today") + #expect(timeRule.rowContext(for: active, usage: used, relativeTo: now) == "18m of 45m used") } @Test("A spent limit reads its usage; unblocking it reads Paused") @@ -251,7 +251,7 @@ struct UsageDisplayTests { let spent = RuleUsage(minutesUsed: 45) let blocking = timeRule.status(at: now, calendar: utc, usage: spent) #expect(blocking.isActive) - #expect(timeRule.rowContext(for: blocking, usage: spent, relativeTo: now) == "45m of 45m used today") + #expect(timeRule.rowContext(for: blocking, usage: spent, relativeTo: now) == "45m of 45m used") timeRule.pausedUntil = utc.date(byAdding: .hour, value: 5, to: now) let paused = timeRule.status(at: now, calendar: utc, usage: spent) @@ -261,7 +261,7 @@ struct UsageDisplayTests { @Test("Overshoot clamps to the budget") func overshootClamps() { let over = RuleUsage(minutesUsed: 60) - #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over) == "45m of 45m used today") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over) == "45m of 45m used") } @Test("Home subtitles prefix the rule kind so the type reads without the icon") @@ -270,12 +270,12 @@ struct UsageDisplayTests { let timeStatus = timeRule.status(at: now, calendar: utc, usage: timeUsage) #expect( UsageDisplay.homeSubtitle(for: timeRule, status: timeStatus, usage: timeUsage, relativeTo: now) - == "Time Limit · 18m of 45m used today") + == "Time Limit · 18m of 45m used") let openUsage = RuleUsage(opensUsed: 2) let openStatus = openRule.status(at: now, calendar: utc, usage: openUsage) #expect( UsageDisplay.homeSubtitle(for: openRule, status: openStatus, usage: openUsage, relativeTo: now) - == "Open Limit · 2 of 5 opens today") + == "Open Limit · 2 of 5 opens") } } diff --git a/OpenAppLockUITests/UsageUITests.swift b/OpenAppLockUITests/UsageUITests.swift index b18e7a8..3f39825 100644 --- a/OpenAppLockUITests/UsageUITests.swift +++ b/OpenAppLockUITests/UsageUITests.swift @@ -21,11 +21,11 @@ final class UsageUITests: XCTestCase { // The row leads with the rule type, then the live usage of the budget. let timeRow = app.element("usageRow-Time Keeper").waitToAppear() XCTAssertTrue(timeRow.label.contains("Time Limit"), "Got: \(timeRow.label)") - XCTAssertTrue(timeRow.label.contains("18m of 45m used today"), "Got: \(timeRow.label)") + XCTAssertTrue(timeRow.label.contains("18m of 45m used"), "Got: \(timeRow.label)") let openRow = app.element("usageRow-Gate Keeper").waitToAppear() XCTAssertTrue(openRow.label.contains("Open Limit"), "Got: \(openRow.label)") - XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)") + XCTAssertTrue(openRow.label.contains("2 of 5 opens"), "Got: \(openRow.label)") } func testSpentBudgetMovesToCurrentlyBlocking() throws { @@ -35,7 +35,7 @@ final class UsageUITests: XCTestCase { // Currently Blocking, carrying its type + usage tracking. let tile = app.buttons["blockedTile-Doom Scroll"].waitToAppear() XCTAssertTrue(tile.label.contains("Time Limit"), "Got: \(tile.label)") - XCTAssertTrue(tile.label.contains("30m of 30m used today"), "Got: \(tile.label)") + XCTAssertTrue(tile.label.contains("30m of 30m used"), "Got: \(tile.label)") // It is no longer tracked under Usage. XCTAssertFalse(