commit ef23fd766891eecb1132631c99c37f45c2e1e02d
parent 2c1772195d543713adbccfa563c6b3a9a57c5fc2
Author: Michael Camilleri <[email protected]>
Date: Fri, 15 May 2026 16:43:51 +0900
Gate lastSeenOtherMoveAt advance on puzzle visibility
A collaborator's edit to a shared game wasn't producing the per-row library dot
or the app-icon badge after the user had previously opened — then backed out of
— the same puzzle in the current app session. Unfortunately, the visibility
check was `currentEntity?.id == gameID` and so this was causing the
notification to be suppressed. NotificationState.activePuzzleID is the
foreground-visible signal that is appropriate for this use case.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 72 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -575,6 +575,19 @@ final class GameStore {
return try context.fetch(request).first
}
+ /// Marks any unseen other-author moves for `gameID` as seen without
+ /// loading the game or disturbing the current-game pointer. Driven by
+ /// `.opened` pings so a sibling device that opened the puzzle is treated
+ /// as "the user saw the moves," keeping the badge in agreement across
+ /// the user's devices.
+ func markOtherMovesSeenWithoutLoading(gameID: UUID) {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first else { return }
+ markOtherMovesSeen(for: entity)
+ }
+
private func markOtherMovesSeen(for entity: GameEntity) {
let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
guard isShared, let latest = entity.latestOtherMoveAt else { return }
diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift
@@ -176,6 +176,65 @@ struct GameStoreUnseenMovesTests {
#expect(store.unseenOtherMovesGameCount() == 0)
}
+ @Test("Inbound moves while the puzzle is visible advance lastSeenOtherMoveAt")
+ func inboundMovesWhilePuzzleVisibleMarkSeen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ let updatedAt = Date(timeIntervalSinceNow: -10)
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: updatedAt,
+ in: persistence.viewContext
+ )
+
+ NotificationState.setActivePuzzleID(gameID)
+ defer { NotificationState.setActivePuzzleID(nil) }
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ #expect(entity.lastSeenOtherMoveAt == updatedAt)
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(!summary.hasUnseenOtherMoves)
+ }
+
+ @Test("Inbound moves after backing out of a puzzle still mark it unseen")
+ func inboundMovesAfterBackOutMarkUnseen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ // Simulate a prior open: `currentEntity` is set inside the store and
+ // `lastSeenOtherMoveAt` is up-to-date with no pending moves. The user
+ // then backs out — `NotificationState.activePuzzleID` clears, but the
+ // store's `currentEntity` deliberately stays put.
+ _ = try store.loadGame(id: gameID)
+ NotificationState.setActivePuzzleID(nil)
+
+ let updatedAt = Date()
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: updatedAt,
+ in: persistence.viewContext
+ )
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ #expect(entity.latestOtherMoveAt == updatedAt)
+ #expect(entity.lastSeenOtherMoveAt == nil)
+ 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()