commit 8d5056949d19c941dc24f22ebc6c300936a7a487
parent 567622351fb6f201c2eac9b0afe58e141b8c1ba4
Author: Michael Camilleri <[email protected]>
Date: Mon, 18 May 2026 00:33:55 +0900
Hold a just-left puzzle active through a short grace window
GameStore.noteIncomingMovesUpdate suppresses the 'unseen moves' badge while the
user is in a game by lock-stepping lastSeenOtherMoveAt to latestOtherMoveAt
whenever NotificationState.isActive(gameID:) is true. That gate —
activePuzzleID == gameID — is evaluated when an inbound moves batch is
processed, not when the user actually watched those moves arrive. Backing out
clears activePuzzleID in .onDisappear, but the freshen/catch-up triggered by
the game list reappearing runs noteIncomingMovesUpdate just after; isActive is
then false, the lock-step is skipped, and the game shows a red badge for moves
the user was just watching on screen.
clearActivePuzzleID(if:) now stamps localActiveUntil[game] = now +
leaveGraceWindow (15s) into the App-Group dictionary already used for
shownByGame, and isActive(gameID:) treats a still-future stamp as active. An
inbound batch that settles a beat after .onDisappear therefore still counts as
seen. Both entry points take a defaulted now: so the boundary is testable.
Scope is the single-device back-out path; the cross-device case is a separate
synced-lease change still to come. Tests: activePuzzleSuppressionClears
rewritten for the grace boundary, plus a new inboundMovesWithinLeaveGraceStaySeen
covering the real open → back-out path.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
3 files changed, 90 insertions(+), 10 deletions(-)
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -20,6 +20,14 @@ enum NotificationState {
private static let activeKey = "notif.activePuzzleID"
private static let shownKey = "notif.shownByGame"
private static let shownPingNamesKey = "notif.shownPingNames"
+ private static let localActiveUntilKey = "notif.localActiveUntil"
+
+ /// Grace window after the user leaves a puzzle during which the game is
+ /// still treated as active. Inbound moves or pings fetched while the
+ /// puzzle was on screen can finish processing a beat after `.onDisappear`
+ /// clears the active ID; without this tail that race re-flags moves the
+ /// user already watched arrive as unseen (and can re-notify for them).
+ static let leaveGraceWindow: TimeInterval = 15
/// Maximum number of recently-presented ping record names retained for
/// dedup. FIFO; older entries are evicted as new ones come in. 200 covers
@@ -45,16 +53,23 @@ enum NotificationState {
}
}
- static func clearActivePuzzleID(if id: UUID) {
+ static func clearActivePuzzleID(if id: UUID, now: Date = Date()) {
guard activePuzzleID() == id else { return }
setActivePuzzleID(nil)
+ stampLocalActive(id, until: now.addingTimeInterval(leaveGraceWindow), now: now)
}
- /// True if the user is currently viewing the puzzle for `gameID`. Active-
- /// puzzle suppression applies to all ping kinds — no notifications fire
- /// while you're already in the puzzle they describe.
- static func isActive(gameID: UUID) -> Bool {
- activePuzzleID() == gameID
+ /// True if the user is currently viewing the puzzle for `gameID`, or left
+ /// it within `leaveGraceWindow`. Active-puzzle suppression applies to all
+ /// ping kinds — no notifications fire while you're already in the puzzle
+ /// they describe — and the grace tail keeps the just-left puzzle covered
+ /// while in-flight inbound work settles.
+ static func isActive(gameID: UUID, now: Date = Date()) -> Bool {
+ if activePuzzleID() == gameID { return true }
+ if let until = localActiveMap()[gameID.uuidString] {
+ return now.timeIntervalSince1970 < until
+ }
+ return false
}
/// True if session activity for `gameID` was shown within `dedupWindow`.
@@ -80,6 +95,22 @@ enum NotificationState {
defaults?.dictionary(forKey: shownKey) as? [String: TimeInterval] ?? [:]
}
+ /// Stamps `id` as locally active until `until`, evicting entries that
+ /// have already expired so the map stays small. Mirrors `shownMap`'s
+ /// App-Group dictionary idiom.
+ private static func stampLocalActive(_ id: UUID, until: Date, now: Date) {
+ guard let defaults else { return }
+ var map = localActiveMap()
+ map[id.uuidString] = until.timeIntervalSince1970
+ let nowTS = now.timeIntervalSince1970
+ map = map.filter { $0.value > nowTS }
+ defaults.set(map, forKey: localActiveUntilKey)
+ }
+
+ private static func localActiveMap() -> [String: TimeInterval] {
+ defaults?.dictionary(forKey: localActiveUntilKey) as? [String: TimeInterval] ?? [:]
+ }
+
/// True if a notification for this specific Ping record name has already
/// been presented. Used to keep the push fast path and the eventual
/// CKSyncEngine catch-up from double-notifying for the same ping.
diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift
@@ -235,6 +235,42 @@ struct GameStoreUnseenMovesTests {
#expect(summary.hasUnseenOtherMoves)
}
+ @Test("Inbound moves within the leave grace after backing out stay seen")
+ func inboundMovesWithinLeaveGraceStaySeen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ _ = try store.loadGame(id: gameID)
+
+ // Simulate the real open → back-out path: the view sets the active
+ // puzzle on appear and clears it on `.onDisappear`, which now opens a
+ // short grace window rather than dropping the active state instantly.
+ NotificationState.setActivePuzzleID(gameID)
+ NotificationState.clearActivePuzzleID(if: gameID)
+ defer { NotificationState.setActivePuzzleID(nil) }
+
+ let updatedAt = Date()
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: updatedAt,
+ in: persistence.viewContext
+ )
+
+ // An inbound batch (or back-out catch-up) that finishes processing a
+ // beat after the view disappeared is still treated as seen — the user
+ // watched these moves arrive while the grid was on screen.
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ #expect(entity.lastSeenOtherMoveAt == updatedAt)
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(!summary.hasUnseenOtherMoves)
+ }
+
@Test("Completed shared games do not show as unseen even with later other-author moves")
func completedSharedGameSuppressesUnseen() throws {
let persistence = makeTestPersistence()
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -5,16 +5,29 @@ import Testing
@Suite("Notification state", .serialized)
struct NotificationStateTests {
- @Test("Active puzzle suppresses only until it is cleared")
+ @Test("Active puzzle stays suppressed through the leave grace, then clears")
func activePuzzleSuppressionClears() {
let gameID = UUID()
+ let base = Date(timeIntervalSince1970: 3_000_000)
NotificationState.setActivePuzzleID(nil)
NotificationState.setActivePuzzleID(gameID)
- #expect(NotificationState.isActive(gameID: gameID))
+ #expect(NotificationState.isActive(gameID: gameID, now: base))
- NotificationState.clearActivePuzzleID(if: gameID)
- #expect(!NotificationState.isActive(gameID: gameID))
+ NotificationState.clearActivePuzzleID(if: gameID, now: base)
+ // The grace tail keeps the just-left puzzle active briefly so
+ // in-flight inbound work still counts as seen.
+ #expect(NotificationState.isActive(gameID: gameID, now: base))
+ #expect(NotificationState.isActive(
+ gameID: gameID,
+ now: base.addingTimeInterval(NotificationState.leaveGraceWindow - 1)
+ ))
+ // At and past the grace boundary it is no longer active.
+ #expect(!NotificationState.isActive(
+ gameID: gameID,
+ now: base.addingTimeInterval(NotificationState.leaveGraceWindow)
+ ))
+ NotificationState.setActivePuzzleID(nil)
}
@Test("Clearing one puzzle does not clear another active puzzle")