crossmate

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

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:
MShared/NotificationState.swift | 43+++++++++++++++++++++++++++++++++++++------
MTests/Unit/GameStoreUnseenMovesTests.swift | 36++++++++++++++++++++++++++++++++++++
MTests/Unit/NotificationStateTests.swift | 21+++++++++++++++++----
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")