commit b58a2b4e7c02c6c7e73aadc8378fb528555dc0f6
parent 6b410f3c6d198034088b7091d8ce14cc436765da
Author: Michael Camilleri <[email protected]>
Date: Fri, 7 Aug 2026 13:30:01 +0900
Keep observed completions clear of unread badges
A shared game completed while the user was watching could regain its
Game List dot and app-icon badge when a collaborator's final Moves
snapshot reached the device after the puzzle closed. The delayed
snapshot advanced the latest-move horizon beyond the older read
watermark even though it belonged to the terminal grid the user had just
seen.
This commit seals the read watermark at the terminal instant for wins
and resignations observed on the device, and mirrors that state to any
Chronicle projection. Completions first learned through sync retain
their existing unread behaviour, so a friend finishing a puzzle while
the user is away still surfaces normally.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
2 files changed, 60 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1519,7 +1519,9 @@ final class GameStore {
mutator.revealCells(allCells)
guard let entity = currentEntity else { return }
- entity.completedAt = Date()
+ let completedAt = Date()
+ entity.completedAt = completedAt
+ markObservedCompletionRead(for: entity, at: completedAt)
// Resignation: no solver, so `completedBy` stays nil — that's how a
// resigned game is told apart from a win.
entity.completedBy = nil
@@ -1630,7 +1632,9 @@ final class GameStore {
!entity.isAccessRevoked,
GameSyncVersion.supports(entity.syncVersion)
else { return false }
- entity.completedAt = Date()
+ let completedAt = Date()
+ entity.completedAt = completedAt
+ markObservedCompletionRead(for: entity, at: completedAt)
// A win: stamp the solver so the Game record can distinguish wins
// from resignations and the completion APN body can name them.
entity.completedBy = authorID
@@ -1652,6 +1656,21 @@ final class GameStore {
return true
}
+ /// Seals the read watermark when this device observes a game becoming
+ /// terminal. Every legitimate move in the finished grid precedes this
+ /// instant, so a collaborator's final Moves snapshot may arrive after the
+ /// user leaves without turning the game unread again. A device that learns
+ /// about completion later through sync does not take this path and still
+ /// surfaces the completed game as unread.
+ private func markObservedCompletionRead(for entity: GameEntity, at completedAt: Date) {
+ let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
+ guard isShared else { return }
+ if (entity.readThroughAt ?? .distantPast) < completedAt {
+ entity.readThroughAt = completedAt
+ }
+ mirrorReadStateToChronicle(from: entity)
+ }
+
/// Signals that a just-completed game's move journal should be uploaded
/// (Phase 2). Fired synchronously on the main actor at completion so the
/// app layer can take a background-execution assertion *before* any
diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift
@@ -471,6 +471,45 @@ struct GameStoreUnreadMovesTests {
#expect(store.unreadOtherMovesGameCount() == 0)
}
+ @Test("A delayed final Moves snapshot does not unread a locally observed completion")
+ func delayedMovesAfterObservedCompletionStaySeen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(
+ persistence: persistence,
+ authorIDProvider: { Self.localAuthorID }
+ )
+ let ctx = persistence.viewContext
+ let (entity, gameID) = try makeSharedGame(in: ctx)
+ _ = try store.loadGame(id: gameID)
+
+ #expect(try store.markCompleted(id: gameID))
+ let completedAt = try #require(entity.completedAt)
+ #expect(entity.readThroughAt == completedAt)
+
+ // The collaborator's final snapshot was authored before the terminal
+ // state but did not reach this device until after the puzzle closed.
+ // It belongs to the grid the user just saw, so it must stay read.
+ NotificationState.setActivePuzzleID(nil)
+ let finalMoveAt = completedAt.addingTimeInterval(-0.001)
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: finalMoveAt,
+ in: ctx
+ )
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ #expect(entity.latestOtherMoveAt == finalMoveAt)
+ #expect(entity.readThroughAt == completedAt)
+ #expect(!store.hasUnreadOtherMoves(gameID: gameID))
+ #expect(store.unreadOtherMovesGameCount() == 0)
+ }
+
@Test("Realtime cell edit updates the open game through the move merger")
func realtimeCellEditUpdatesOpenGame() throws {
let persistence = makeTestPersistence()