commit 9fb76f64c1b0134e7ab842f2f19763bcef5e7971
parent 68a938ea504cfb33e2bd38a64f7195d7c85ee57d
Author: Michael Camilleri <[email protected]>
Date: Fri, 26 Jun 2026 14:45:34 +0900
Display an unread dot on completed games
When a co-player finished or resigned a shared puzzle, the app icon
badge counted the completion but the game's own tile in the Game List
showed no unread dot, so the user could see the badge without any way to
tell which finished game it referred to. A peer's win or resignation is
itself an unseen event worth flagging.
This commit drops the `completedAt == nil` exclusion from both
computeHasUnread, which drives the per-game dot, and the
unreadOtherMovesPredicate behind the badge count, so the two stay in
step. The move that finishes or resigns a game lands as a later
other-author move and bumps latestOtherMoveAt, so a finished game now
flags as unread until the user opens it; reviewing it advances
readThroughAt via markOtherMovesRead and clears the dot like any other.
A game the user finished themselves stays clear, since their own move
never advances latestOtherMoveAt and their watermark is already current.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -154,7 +154,6 @@ struct GameSummary: Identifiable, Equatable {
)
self.hasUnreadOtherMoves = Self.computeHasUnread(
isShared: self.isShared,
- completedAt: entity.completedAt,
latest: entity.latestOtherMoveAt,
// The unread badge keys off the read *watermark*, not the presence
// lease. The lease (`lastReadOtherMoveAt`) is forward-dated while
@@ -164,13 +163,17 @@ struct GameSummary: Identifiable, Equatable {
)
}
+ /// A game is unread when a peer's move is newer than this account's read
+ /// watermark. Completed games count too: a co-player finishing or resigning
+ /// is itself an unseen event (the badge ledger already flags it from the
+ /// completion push), and opening the finished game to review it advances
+ /// `readThroughAt` via `markOtherMovesRead`, clearing the dot like any other.
fileprivate static func computeHasUnread(
isShared: Bool,
- completedAt: Date?,
latest: Date?,
readThrough: Date?
) -> Bool {
- guard isShared, completedAt == nil, let latest else { return false }
+ guard isShared, let latest else { return false }
guard let readThrough else { return true }
return latest > readThrough
}
@@ -840,7 +843,6 @@ final class GameStore {
// badge for moves that arrived after the user backgrounded.
NSPredicate(
format: "(databaseScope == 1 OR ckShareRecordName != nil) "
- + "AND completedAt == nil "
+ "AND latestOtherMoveAt != nil "
+ "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)"
)
diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift
@@ -412,12 +412,15 @@ struct GameStoreUnreadMovesTests {
#expect(entity.lastReadOtherMoveAt == refreshed)
}
- @Test("Completed shared games do not show as unseen even with later other-author moves")
- func completedSharedGameSuppressesUnseen() throws {
+ @Test("Completed shared games show as unseen when a peer finished or resigned unseen")
+ func completedSharedGameSurfacesUnseen() throws {
let persistence = makeTestPersistence()
let store = makeTestStore(persistence: persistence)
let ctx = persistence.viewContext
+ // A peer's win or resignation is itself an unseen event: the move that
+ // finished the game lands as a later other-author move, so the finished
+ // game should flag as unread until the user opens it to review.
let (entity, gameID) = try makeSharedGame(in: ctx)
entity.completedAt = Date(timeIntervalSinceNow: -100)
try ctx.save()
@@ -436,7 +439,13 @@ struct GameStoreUnreadMovesTests {
)
let summary = try #require(GameSummary(entity: entity))
- #expect(!summary.hasUnreadOtherMoves)
+ #expect(summary.hasUnreadOtherMoves)
+ #expect(store.unreadOtherMovesGameCount() == 1)
+
+ // Reviewing the finished game advances the read watermark and clears it.
+ store.advanceReadThrough(gameID: gameID, through: Date())
+ let reviewed = try #require(GameSummary(entity: entity))
+ #expect(!reviewed.hasUnreadOtherMoves)
#expect(store.unreadOtherMovesGameCount() == 0)
}