Compare commits

..

2 Commits

Author SHA1 Message Date
0a0d00f53a docs: mandate plan-first, red-green TDD, and pre-commit UI validation
Make the three workflow expectations explicit, non-negotiable defaults:
always plan before execution, always use red-green TDD, and always
attempt manual UI validation before committing (skippable only when the
tooling is unavailable, and only with an explicit callout + handoff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:32:35 -04:00
e5f571a63e fix: show a generic "App Blocked" shield instead of mis-attributing a rule
The shield titled itself with the first enabled rule whose selection
contained the app, regardless of whether that rule was the one actually
blocking. When several rules covered the same app (e.g. a dormant
schedule + the active limit) it named the wrong rule.

Drop the rule-name lookup entirely: every shield now carries a generic
"App Blocked" title. Open-limit shields keep their functional detail
(running count + "Open (N left)" button) beneath that title. The
text-only decision moves into a pure, unit-tested ShieldPresentation in
Shared/, and the now-dead ShieldLookup.snapshot(containingApplication:)
is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:32:26 -04:00
6 changed files with 143 additions and 56 deletions

View File

@@ -79,10 +79,22 @@ docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards
## Workflow expectations (user preference) ## Workflow expectations (user preference)
- **Red-green TDD**: update `docs/RULES_FEATURE_SPEC.md` first for behavior These three are non-negotiable defaults — follow them on every task, not only
changes, write the failing test, run it (compile failure counts as red), when reminded:
implement, re-run focused tests, then the full suite. Run tests often and
fail fast. - **Always plan before execution.** Think through and lay out the approach (a
written plan / plan mode for anything non-trivial) and confirm scope before
editing code. Do not start changing files until the plan is clear.
- **Always use red-green TDD.** Update `docs/RULES_FEATURE_SPEC.md` first for
behavior changes, write the failing test, run it (compile failure counts as
red), implement, re-run focused tests, then the full suite. Run tests often
and fail fast.
- **Always attempt to validate the UI manually before committing.** Build and
run the app (simulator/device) and visually confirm the change behaves as
intended. This step **may be skipped only when such tooling is unavailable**
(e.g. the Xcode MCP / a simulator is not reachable in the session) — in that
case, say so explicitly and hand the verification back to the user rather
than silently skipping it.
- Conventional commits (`feat:`, `fix:`, `refactor:` …). **Agent attribution is - Conventional commits (`feat:`, `fix:`, `refactor:` …). **Agent attribution is
required**: every commit an agent authors or co-authors must end with a required**: every commit an agent authors or co-authors must end with a
`Co-Authored-By:` trailer naming the specific agent/model that did the work, `Co-Authored-By:` trailer naming the specific agent/model that did the work,

View File

@@ -9,28 +9,29 @@ import UIKit
/// Customizes the system shield. Apps under an open-limit rule show how many /// Customizes the system shield. Apps under an open-limit rule show how many
/// opens were used and offer an "Open" secondary button while opens remain; /// opens were used and offer an "Open" secondary button while opens remain;
/// everything else gets a plain blocked shield named after its rule. /// everything else gets a plain blocked shield. Shields never name a rule the
/// text is decided by the pure `ShieldPresentation`.
final class ShieldConfigurationExtension: ShieldConfigurationDataSource { final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
override func configuration(shielding application: Application) -> ShieldConfiguration { override func configuration(shielding application: Application) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) } guard let token = application.token else { return configuration(for: .blocked) }
return configuration(forApplicationToken: token) return configuration(forApplicationToken: token)
} }
override func configuration( override func configuration(
shielding application: Application, in category: ActivityCategory shielding application: Application, in category: ActivityCategory
) -> ShieldConfiguration { ) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) } guard let token = application.token else { return configuration(for: .blocked) }
return configuration(forApplicationToken: token) return configuration(forApplicationToken: token)
} }
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration { override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil) configuration(for: .blocked)
} }
override func configuration( override func configuration(
shielding webDomain: WebDomain, in category: ActivityCategory shielding webDomain: WebDomain, in category: ActivityCategory
) -> ShieldConfiguration { ) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil) configuration(for: .blocked)
} }
private func configuration( private func configuration(
@@ -41,45 +42,26 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
let snapshot = ShieldLookup.openLimitSnapshot( let snapshot = ShieldLookup.openLimitSnapshot(
containingApplication: token, in: snapshots) containingApplication: token, in: snapshots)
else { else {
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name return configuration(for: .blocked)
return blockedConfiguration(ruleName: name)
} }
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now) let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
let remaining = max(0, snapshot.maxOpens - usage.opensUsed) return configuration(
guard remaining > 0 else { for: .openLimit(
return ShieldConfiguration( opensUsed: usage.opensUsed,
backgroundBlurStyle: .systemMaterial, maxOpens: snapshot.maxOpens,
title: .init(text: snapshot.name, color: .label), sessionMinutes: MonitoringPlan.openSessionMinutes))
subtitle: .init(
text: "No opens left today — the block lifts tomorrow.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue
)
}
return ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: snapshot.name, color: .label),
subtitle: .init(
text: "Opened \(usage.opensUsed) of \(snapshot.maxOpens) times today. "
+ "Each open lasts \(MonitoringPlan.openSessionMinutes) minutes.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: .init(
text: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)",
color: .systemBlue)
)
} }
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration { private func configuration(for presentation: ShieldPresentation) -> ShieldConfiguration {
ShieldConfiguration( ShieldConfiguration(
backgroundBlurStyle: .systemMaterial, backgroundBlurStyle: .systemMaterial,
title: .init(text: ruleName ?? "Blocked", color: .label), title: .init(text: presentation.title, color: .label),
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel), subtitle: .init(text: presentation.subtitle, color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white), primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: presentation.secondaryButton.map {
.init(text: $0, color: .systemBlue)
}
) )
} }
} }

View File

@@ -0,0 +1,53 @@
//
// ShieldPresentationTests.swift
// OpenAppLockTests
//
import Testing
@testable import OpenAppLock
@Suite("Shield presentation")
struct ShieldPresentationTests {
@Test("Fully-blocked shield is generic and names no rule")
func blockedIsGeneric() {
let presentation = ShieldPresentation.blocked
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle == "This app is blocked by OpenAppLock.")
#expect(presentation.secondaryButton == nil)
}
@Test("Open-limit shield with opens remaining shows counts and an Open button")
func openLimitWithOpensRemaining() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 2, maxOpens: 5, sessionMinutes: 15)
// Title is generic never the rule name.
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle.contains("Opened 2 of 5 times today"))
#expect(presentation.subtitle.contains("15 minutes"))
#expect(presentation.secondaryButton == "Open (3 left)")
}
@Test("A single remaining open reads in the singular")
func openLimitSingularRemaining() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 4, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.secondaryButton == "Open (1 left)")
}
@Test("Spent open-limit shield drops the Open button and reads as blocked")
func openLimitExhausted() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 5, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle == "No opens left today — the block lifts tomorrow.")
#expect(presentation.secondaryButton == nil)
}
@Test("Over-spending never yields a negative or non-nil Open button")
func openLimitOverSpent() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 9, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.secondaryButton == nil)
}
}

View File

@@ -29,16 +29,4 @@ enum ShieldLookup {
.categoryTokens.contains(token) .categoryTokens.contains(token)
} }
} }
/// Any enabled rule whose selection includes the application (for naming
/// plain blocked shields).
static func snapshot(
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.applicationTokens.contains(token)
}
}
} }

View File

@@ -0,0 +1,47 @@
//
// ShieldPresentation.swift
// OpenAppLock
//
/// The text a shield shows, decided independently of any UI type so it can be
/// unit tested. Shields never name the rule that blocks an app: when several
/// rules cover the same app the responsible rule can't be told apart, so every
/// shield carries the same generic "App Blocked" title. Open-limit shields add
/// their functional detail (remaining opens + an "Open" button) beneath it.
struct ShieldPresentation: Equatable {
let title: String
let subtitle: String
/// Label for the secondary "Open" button, or `nil` when the shield offers
/// no way through (a full block, or a spent open-limit budget).
let secondaryButton: String?
static let blockedTitle = "App Blocked"
/// A plain, fully-blocked app: no counts, no way through.
static let blocked = ShieldPresentation(
title: blockedTitle,
subtitle: "This app is blocked by OpenAppLock.",
secondaryButton: nil
)
/// An open-limit shield. While opens remain it shows the running count and
/// an "Open (N left)" button; once spent it reads like a plain block.
static func openLimit(
opensUsed: Int, maxOpens: Int, sessionMinutes: Int
) -> ShieldPresentation {
let remaining = max(0, maxOpens - opensUsed)
guard remaining > 0 else {
return ShieldPresentation(
title: blockedTitle,
subtitle: "No opens left today — the block lifts tomorrow.",
secondaryButton: nil
)
}
return ShieldPresentation(
title: blockedTitle,
subtitle: "Opened \(opensUsed) of \(maxOpens) times today. "
+ "Each open lasts \(sessionMinutes) minutes.",
secondaryButton: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)"
)
}
}

View File

@@ -485,9 +485,14 @@ Xh", "Xh left") is **derived**, not stored.
`OpenSessionStore` reports a still-running granted open for it — so the `OpenSessionStore` reports a still-running granted open for it — so the
foreground loop establishes the turnstile for newly created rules and never foreground loop establishes the turnstile for newly created rules and never
re-locks an app mid-session. re-locks an app mid-session.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit - **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): every shield
shields show "Opened X of N times today" with an "Open (Y left)" secondary carries the same generic **"App Blocked"** title — rule names are never shown,
button; other shields show the blocking rule's name. since the rule a shield is attributed to cannot be determined reliably when
several rules cover the same app. Open-limit shields keep their functional
detail under that title ("Opened X of N times today" with an "Open (Y left)"
secondary button while opens remain); all other shields just read "This app is
blocked by OpenAppLock." The text-only decision lives in the pure, unit-tested
`ShieldPresentation` (in `Shared/`).
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press - **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, records the session spends one open in the ledger, lifts the rule's shield, records the session
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session