crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 2446484fe1026f01254857662d038b029eed177b
parent 62674e9f3aed9ba7f69b4063abfb35bb013f63e6
Author: Michael Camilleri <[email protected]>
Date:   Fri, 15 May 2026 14:41:49 +0900

Suppress redundant freshens between push signals

Diagnostic capture during a typical session — Game List appearance,
pull-to-refresh, entry into a Puzzle Grid, square filling, Game List
reappearance — showed freshenGameListScope running its full discovery +
game/moves catch-up on every view event.

Silent pushes from iCloud are the primary 'something changed' signal; the
freshen exists as a backstop for the case where Apple drops a silent push or a
share-accept notification. The freshen is now gated per-scope on a 5-minute
cooldown that skips when no inbound push has arrived since the last successful
run. The bypass conditions: .manual (pull-to-refresh) always runs, and any push
later than the last successful freshen always runs. Otherwise a single trace
line records the skip with the elapsed time. The gate covers both discovery and
the game/moves catch-up — the same logic applies to both, and discovery's
per-scope round-trip is worth saving when nothing else fired it.

The timestamp updates only on catch-up success (the more expensive
phase), so a transient discovery error doesn't suppress the next
attempt indefinitely. lastRemoteNotificationAt is the existing global
push timestamp reused as the bypass signal — slight over-trigger across
scopes, but harmless because the bypassed scope just runs and either
finds something or doesn't.

Separately, PlayerSelectionPublisher's default debounce moves from
300ms to 500ms to match MovesUpdater. A typing burst now fires both
Player and Moves debounces at the same instant rather than 200ms apart,
giving CKSyncEngine a real chance to batch the two saves into one HTTP
request instead of two.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCrossmate/Sync/PlayerSelectionPublisher.swift | 2+-
2 files changed, 79 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -63,6 +63,17 @@ final class AppServices { private var isHandlingSharedRemoteNotification = false private var isFresheningPrivateGameList = false private var isFresheningSharedGameList = false + /// Wall-clock timestamp of the last successful game-list freshen per + /// scope, used to suppress redundant polls when no inbound push has + /// arrived since. Pushes own freshness; the freshen (zone discovery + + /// game/moves catch-up) is only a backstop for the case where Apple + /// drops a silent push or a share-accept notification. + private var lastPrivateGameListFreshenAt: Date? + private var lastSharedGameListFreshenAt: Date? + /// Maximum staleness budget for the game list before an unprompted view + /// event re-runs the freshen. Bypassed by `.manual` and by any push that + /// arrived after the last successful freshen. + private let gameListFreshenCooldown: TimeInterval = 300 private var fresheningPuzzleGridKeys: Set<String> = [] private var isGameListVisible = false @@ -397,6 +408,9 @@ final class AppServices { reason: FreshenReason ) async { let reasonLabel = reason.diagnosticLabel + if !shouldRunGameListFreshen(scope: scope, reason: reason, label: label) { + return + } guard beginGameListFreshen(scope: scope, label: label, reason: reasonLabel) else { return } @@ -405,8 +419,70 @@ final class AppServices { await syncMonitor.run("freshen game list \(reasonLabel): \(label) discovery") { _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope) } - await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") { - _ = try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope) + let catchUpResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") { + try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope) + } + if catchUpResult != nil { + noteGameListFreshenCompleted(scope: scope) + } + } + + /// Decides whether a game-list freshen should run for this scope. The + /// freshen polls each active zone for new records and enumerates + /// database zones for newly-shared games; between inbound pushes it + /// can't surface anything new, so we skip when the push signal hasn't + /// moved since the last successful run and the staleness budget hasn't + /// been exhausted. `.manual` (pull-to-refresh) always runs because the + /// user explicitly asked. + private func shouldRunGameListFreshen( + scope: CKDatabase.Scope, + reason: FreshenReason, + label: String + ) -> Bool { + if reason == .manual { + return true + } + guard let last = lastGameListFreshenAt(scope: scope) else { + return true + } + if let pushAt = lastRemoteNotificationAt, pushAt > last { + return true + } + let elapsed = Date().timeIntervalSince(last) + if elapsed >= gameListFreshenCooldown { + return true + } + let elapsedSeconds = Int(elapsed.rounded()) + syncMonitor.note( + "freshen game list \(reason.diagnosticLabel): \(label) skipped (cooldown, last \(elapsedSeconds)s ago)" + ) + return false + } + + private func lastGameListFreshenAt(scope: CKDatabase.Scope) -> Date? { + switch scope { + case .private: + return lastPrivateGameListFreshenAt + case .shared: + return lastSharedGameListFreshenAt + case .public: + return nil + @unknown default: + return nil + } + } + + private func noteGameListFreshenCompleted(scope: CKDatabase.Scope) { + let now = Date() + switch scope { + case .private: + lastPrivateGameListFreshenAt = now + case .shared: + lastSharedGameListFreshenAt = now + case .public: + return + @unknown default: + return } } diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift @@ -29,7 +29,7 @@ actor PlayerSelectionPublisher { private var fallbackName: String = "" init( - debounceInterval: Duration = .milliseconds(300), + debounceInterval: Duration = .milliseconds(500), persistence: PersistenceController, sink: @escaping @Sendable (UUID, String) async -> Void, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }