Review: ask on value milestones, not exports

The old gate needed two exports, three days of install age, one prompt per
version and 120 days between prompts. Aptabase shows only 9% of users ever
export and 5% export twice, so `Review Prompt Requested` never fired once in
19 days and the store has a single rating.

Replace the export counter with three milestones -- a fully configured system,
an opened bill of materials, a completed export -- each counted at most once
per install, and ask once two different ones are reached. Recording and asking
are now separate: milestones are booked mid-task, where StoreKit and Play drop
the request, so the ask happens from calm screens only (system overview,
export preview dismissal, share sheet dismissal).

Two fixes that made the old prompt lose its slot for good: the throttle keys
were written before checking for a foreground scene, and the export trigger
fired while the Quick Look sheet was still animating away. Both platforms now
mark the throttle only once the store API really has a review flow to show.

Add a Settings entry that links straight to the store review page. It is never
throttled, and 45% of users open Settings versus 9% who export. New analytics
event `Review Milestone Reached` makes the funnel measurable.

Existing installs keep their legacy export credit and still need a second
milestone before being asked.
This commit is contained in:
2026-08-19 15:07:46 +02:00
parent 165827c4d4
commit 9673bde107
21 changed files with 479 additions and 140 deletions

View File

@@ -0,0 +1,114 @@
import Foundation
import Testing
@testable import Cable
/// The rating gate is invisible in the app — the OS may swallow the dialog — so its rules are
/// only observable here. Serialized because `ReviewPrompt.store` is process-wide state.
@Suite(.serialized)
struct ReviewPromptTests {
private func withFreshStore(_ body: (UserDefaults) -> Void) {
let name = "review.tests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a test defaults suite")
return
}
let previous = ReviewPrompt.store
ReviewPrompt.store = defaults
defer {
ReviewPrompt.store = previous
defaults.removePersistentDomain(forName: name)
}
body(defaults)
}
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
@Test func oneMilestoneIsNotEnough() {
withFreshStore { _ in
ReviewPrompt.record(.billOfMaterials)
#expect(ReviewPrompt.isEligible == false)
}
}
@Test func twoDistinctMilestonesOpenTheGate() {
withFreshStore { _ in
ReviewPrompt.record(.systemPlanned)
ReviewPrompt.record(.billOfMaterials)
#expect(ReviewPrompt.isEligible)
}
}
@Test func repeatingOneMilestoneNeverOpensTheGate() {
withFreshStore { store in
for _ in 0..<5 { ReviewPrompt.record(.systemPlanned) }
#expect(store.stringArray(forKey: "review.milestones") == ["systemPlanned"])
#expect(ReviewPrompt.isEligible == false)
}
}
@Test func aRecentPromptBlocksTheGate() {
withFreshStore { store in
ReviewPrompt.record(.systemPlanned)
ReviewPrompt.record(.exported)
store.set(Date().timeIntervalSince1970 - 10 * 86_400, forKey: "review.lastPromptDate")
#expect(ReviewPrompt.isEligible == false)
store.set(Date().timeIntervalSince1970 - 91 * 86_400, forKey: "review.lastPromptDate")
#expect(ReviewPrompt.isEligible)
}
}
@Test func theCurrentVersionIsOnlyAskedOnce() {
withFreshStore { store in
ReviewPrompt.record(.systemPlanned)
ReviewPrompt.record(.exported)
store.set(appVersion, forKey: "review.lastPromptedVersion")
#expect(ReviewPrompt.isEligible == false)
store.set("0.0.0-old", forKey: "review.lastPromptedVersion")
#expect(ReviewPrompt.isEligible)
}
}
/// Installs that already exported under the previous gate keep that credit, but still need a
/// second, genuine milestone before we ask.
@Test func migrationCarriesLegacyExportCreditAndDropsOldKeys() {
withFreshStore { store in
store.set(1, forKey: "review.successfulExportCount")
store.set(true, forKey: "review.migrationDone")
store.set(Date().timeIntervalSince1970, forKey: "review.firstLaunchDate")
ReviewPrompt.migrateIfNeeded(isFirstLaunch: false)
#expect(store.stringArray(forKey: "review.milestones") == ["exported"])
#expect(store.string(forKey: "review.userType") == "existing")
#expect(store.object(forKey: "review.successfulExportCount") == nil)
#expect(store.object(forKey: "review.migrationDone") == nil)
#expect(store.object(forKey: "review.firstLaunchDate") == nil)
#expect(ReviewPrompt.isEligible == false)
ReviewPrompt.record(.billOfMaterials)
#expect(ReviewPrompt.isEligible)
}
}
@Test func aFreshInstallStartsWithNoCredit() {
withFreshStore { store in
ReviewPrompt.migrateIfNeeded(isFirstLaunch: true)
#expect(store.stringArray(forKey: "review.milestones") == nil)
#expect(store.string(forKey: "review.userType") == "new")
#expect(ReviewPrompt.isEligible == false)
}
}
@Test func theManualReviewLinkTargetsTheStoreListing() {
#expect(
ReviewPrompt.writeReviewURL.absoluteString
== "https://apps.apple.com/app/id6752443870?action=write-review"
)
}
}