crossmate

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

commit 6d56e4082f2a6634ec426e9fe6e3dd773b02da50
parent 8d36bbed1260f5dc2b8d115ae53e366852ca890a
Author: Michael Camilleri <[email protected]>
Date:   Thu, 30 Jul 2026 20:13:06 +0900

Synchronise Completed game badges across devices

A completed shared game could lose its Game List badge when its
Chronicle replaced the live Game while the app-icon badge remained set.
Opening the Chronicle also addressed its derived archive UUID, so it did
not dismiss the original game's notification or publish a read receipt
sibling devices understood.

This commit treats the live Game and Chronicle as one canonical unread
identity. It mirrors monotonic latest-move and read-through watermarks
onto the visible Chronicle, deduplicates the app-icon count by the
original game ID and clears the notification ledger when the Chronicle
opens.

Sibling Player watermarks now update the Chronicle projection. A
Completed account-seen push can also advance an archive-only local
watermark after live-zone retirement, allowing devices to converge even
when the hidden live row is gone.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 15++++++++++++++-
MCrossmate/Persistence/GameStore.swift | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MCrossmate/Services/AppServices.swift | 11+++++++++++
MCrossmate/Sync/Archive.swift | 29+++++++++++++++++++++++++++++
MCrossmate/Sync/RecordApplier.swift | 9+++++++++
MTests/Unit/ArchiveTests.swift | 33+++++++++++++++++++++++++++++++++
MTests/Unit/Sync/PlayerRecordPresenceTests.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 239 insertions(+), 35 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -955,7 +955,12 @@ private struct PuzzleDisplayView: View { roster = nil loadError = nil loadingMessage = "Loading puzzle…" - Task { await services.badge.dismissDeliveredNotifications(for: gameID) } + let canonicalGameID = store.canonicalGameID(for: gameID) + Task { + await services.badge.dismissDeliveredNotifications( + for: canonicalGameID + ) + } do { if let plan = NYTPuzzleUpgrader.plan(for: gameID, store: store) { @@ -1118,6 +1123,14 @@ private struct PuzzleDisplayView: View { "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded Chronicle roster " + "(live lifecycle disabled)" ) + // The Chronicle is a local projection with a derived storage ID. + // Publish the read watermark through the retained live game's + // original identity so sibling devices clear the same Completed + // tile and app-icon badge. + await services.publishReadCursor( + for: store.canonicalGameID(for: gameID), + mode: .currentTime + ) return } if isShared && preferences.isICloudSyncEnabled { diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1025,6 +1025,7 @@ final class GameStore { (entity.readThroughAt ?? .distantPast) < latest { entity.readThroughAt = latest } + mirrorReadStateToChronicle(from: entity) } if context.hasChanges { @@ -1149,32 +1150,18 @@ final class GameStore { /// `hasUnreadOtherMoves` heuristic the library list uses, aggregated as /// a count for the app-icon badge. func unreadOtherMovesGameCount() -> Int { - let request = NSFetchRequest<NSNumber>(entityName: "GameEntity") - request.resultType = .countResultType - request.predicate = unreadOtherMovesPredicate - return (try? context.count(for: request)) ?? 0 + unreadOtherMovesGameTimes().count } /// The same heuristic as `unreadOtherMovesGameCount`, returning the /// individual game IDs so the App Group `BadgeState` set can be unioned /// with NSE-added entries. func unreadOtherMovesGameIDs() -> Set<UUID> { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = unreadOtherMovesPredicate - request.propertiesToFetch = ["id"] - let rows = (try? context.fetch(request)) ?? [] - return Set(rows.compactMap(\.id)) + Set(unreadOtherMovesGameTimes().keys) } func hasUnreadOtherMoves(gameID: UUID) -> Bool { - let request = NSFetchRequest<NSNumber>(entityName: "GameEntity") - request.resultType = .countResultType - request.fetchLimit = 1 - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - NSPredicate(format: "id == %@", gameID as CVarArg), - unreadOtherMovesPredicate - ]) - return ((try? context.count(for: request)) ?? 0) > 0 + unreadOtherMovesGameIDs().contains(canonicalGameID(for: gameID)) } /// The same heuristic as `unreadOtherMovesGameIDs`, but paired with each @@ -1193,7 +1180,10 @@ final class GameStore { var result: [UUID: Date] = [:] for row in rows { if let id = row.id, let at = row.latestOtherMoveAt { - result[id] = at + let canonicalID = row.ckRecordName.flatMap( + Archive.originalGameID(fromName:) + ) ?? id + result[canonicalID] = max(result[canonicalID] ?? .distantPast, at) } } return result @@ -1225,9 +1215,13 @@ final class GameStore { private var unreadOtherMovesPredicate: NSPredicate { // Keyed off the read *watermark* (`readThroughAt`), not the forward- // dated presence lease (`lastReadOtherMoveAt`) — matches - // `GameSummary.computeHasUnread`. + // `GameSummary.computeHasUnread`. A shared Chronicle is locally owned + // (`databaseScope == 0`) but still participates after retirement + // deletes its live row; `unreadOtherMovesGameTimes` canonicalizes the + // overlapping rows to one original game ID before counting. NSPredicate( - format: "(databaseScope == 1 OR ckShareRecordName != nil) " + format: "(databaseScope == 1 OR ckShareRecordName != nil " + + "OR archiveParticipants != nil) " + "AND latestOtherMoveAt != nil " + "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)" ) @@ -1259,6 +1253,30 @@ final class GameStore { return (game, mutator) } + /// The stable identity used by notifications and unread state. A + /// materialized Chronicle has its own Core Data ID so it can coexist with + /// the retained live row, but its record name preserves the original game + /// ID that pushes and Player records use. + func canonicalGameID(for storedGameID: UUID) -> UUID { + guard let entity = fetchGameEntity(id: storedGameID), + let recordName = entity.ckRecordName, + let originalID = Archive.originalGameID(fromName: recordName) + else { return storedGameID } + return originalID + } + + /// Whether either persisted representation in a live-game/Chronicle family + /// is terminal. Used only for account-level read receipts: unlike an active + /// puzzle, a completed game cannot gain a later move after a sibling says + /// it has been opened, so the receiving device can safely advance its local + /// Chronicle watermark immediately. + func isCompletedGameFamily(gameID: UUID) -> Bool { + let canonicalID = canonicalGameID(for: gameID) + return readStateEntities(canonicalGameID: canonicalID).contains { + $0.completedAt != nil + } + } + // MARK: - Duplicate detection /// Returns the ID of an existing game for the same source. Exact source @@ -2029,31 +2047,72 @@ final class GameStore { /// Returns `true` if the watermark moved. @discardableResult func advanceReadThrough(gameID: UUID, through: Date) -> Bool { - 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 false } - let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 - guard isShared else { return false } - guard (entity.readThroughAt ?? .distantPast) < through else { return false } - entity.readThroughAt = through + let canonicalID = canonicalGameID(for: gameID) + let entities = readStateEntities(canonicalGameID: canonicalID) + guard entities.contains(where: { + $0.ckShareRecordName != nil + || $0.databaseScope == 1 + || isArchivedSharedGame($0) + }) else { return false } + var changed = false + for entity in entities where (entity.readThroughAt ?? .distantPast) < through { + entity.readThroughAt = through + changed = true + } + guard changed else { return false } saveContext("advanceReadThrough") onUnreadOtherMovesChanged?() return true } private func markOtherMovesRead(for entity: GameEntity) { - let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 - guard isShared, let latest = entity.latestOtherMoveAt else { return } + guard let storedID = entity.id else { return } + let canonicalID = canonicalGameID(for: storedID) + let entities = readStateEntities(canonicalGameID: canonicalID) + let isShared = entities.contains { + $0.ckShareRecordName != nil + || $0.databaseScope == 1 + || isArchivedSharedGame($0) + } + guard isShared, + let latest = entities.compactMap(\.latestOtherMoveAt).max() + else { return } // Advances the *read watermark* (`readThroughAt`), not the presence // lease (`lastReadOtherMoveAt`). Opening the game means the user has now // seen every other-author move up to `latest`; the lease is a separate, // forward-dated "actively present" horizon owned by `setReadCursor`. - if (entity.readThroughAt ?? .distantPast) < latest { - entity.readThroughAt = latest - saveContext("markOtherMovesRead") - onUnreadOtherMovesChanged?() + var changed = false + for candidate in entities where (candidate.readThroughAt ?? .distantPast) < latest { + candidate.readThroughAt = latest + changed = true } + guard changed else { return } + saveContext("markOtherMovesRead") + onUnreadOtherMovesChanged?() + } + + private func readStateEntities(canonicalGameID: UUID) -> [GameEntity] { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate( + format: "id IN %@", + [ + canonicalGameID, + Archive.archiveGameID(for: canonicalGameID) + ] + ) + return (try? context.fetch(request)) ?? [] + } + + private func mirrorReadStateToChronicle(from live: GameEntity) { + guard let liveID = live.id else { return } + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate( + format: "id == %@", + Archive.archiveGameID(for: liveID) as CVarArg + ) + request.fetchLimit = 1 + guard let chronicle = try? context.fetch(request).first else { return } + Archive.mirrorReadState(from: live, to: chronicle) } private func seedFromSample() throws -> (GameEntity, Puzzle) { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -2121,6 +2121,17 @@ final class AppServices { return true } let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil) + if store.isCompletedGameFamily(gameID: gameID) { + // A completed game is immutable. Treat the sibling's explicit + // open as a read receipt immediately, including when retirement + // has already removed the live row and only the Chronicle + // projection remains. The Player.readThrough echo is still the + // durable convergence path while the live zone exists. + store.advanceReadThrough( + gameID: gameID, + through: min(presenceUntil, Date()) + ) + } syncMonitor.note( "push(accountSeen): sibling saw \(gameID.uuidString.prefix(8)) " + "presenceUntil=\(presenceUntil.ISO8601Format()) " + diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -130,6 +130,22 @@ enum Archive { return nil } + /// Copies the account's mutable unread state from a completed live Game + /// onto its local Chronicle projection. The Chronicle payload itself stays + /// immutable; these fields are device-local projections of the canonical + /// live-game state so the visible Completed tile survives the handoff from + /// the hidden Game row. + static func mirrorReadState(from live: GameEntity, to chronicle: GameEntity) { + if let latest = live.latestOtherMoveAt, + (chronicle.latestOtherMoveAt ?? .distantPast) < latest { + chronicle.latestOtherMoveAt = latest + } + if let readThrough = live.readThroughAt, + (chronicle.readThroughAt ?? .distantPast) < readThrough { + chronicle.readThroughAt = readThrough + } + } + static func isArchiveZone(_ zoneName: String) -> Bool { zoneName == self.zoneName || zoneName.hasPrefix("archive-") } @@ -904,6 +920,19 @@ enum Archive { ? payload.participants.map(\.authorID).sorted().joined(separator: ",") : nil entity.isSupersededByChronicle = false + // When the live row is still present, it owns the account's mutable + // unread watermark. Mirror that state before the Chronicle becomes the + // visible Completed tile (and before retirement may delete the live + // row), without putting mutable state into the frozen archive payload. + let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") + liveRequest.predicate = NSPredicate( + format: "id == %@", + payload.originalGameID as CVarArg + ) + liveRequest.fetchLimit = 1 + if let live = try? ctx.fetch(liveRequest).first { + mirrorReadState(from: live, to: entity) + } // Pending Chronicles carry no partial journal, but remain visibly // retryable until reconciliation either captures every device or the // retention deadline turns them into a terminal no-replay fallback. diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -401,6 +401,15 @@ extension SyncEngine { let game = entity.game, (game.readThroughAt ?? .distantPast) < incomingReadThrough { game.readThroughAt = incomingReadThrough + let archiveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") + archiveRequest.predicate = NSPredicate( + format: "id == %@", + Archive.archiveGameID(for: gameID) as CVarArg + ) + archiveRequest.fetchLimit = 1 + if let chronicle = try? ctx.fetch(archiveRequest).first { + Archive.mirrorReadState(from: game, to: chronicle) + } } // The remaining value fields are only adopted when the incoming record diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -592,6 +592,21 @@ struct ArchiveTests { #expect(summary.isShared) #expect(Set(summary.allParticipants.map(\.authorID)) == ["alice", "bob"]) #expect(summary.completedAt != nil) + + // Once retirement removes the live row, a shared Chronicle remains a + // valid unread source for the app-icon count. + game.latestOtherMoveAt = payload.completedAt + game.readThroughAt = payload.completedAt.addingTimeInterval(-1) + try ctx.save() + let store = makeTestStore(persistence: persistence) + #expect(store.unreadOtherMovesGameCount() == 1) + #expect(store.unreadOtherMovesGameIDs() == [original]) + #expect(store.isCompletedGameFamily(gameID: original)) + #expect(store.advanceReadThrough( + gameID: original, + through: payload.completedAt + )) + #expect(store.unreadOtherMovesGameCount() == 0) } @Test("a materialized archive replays the full multi-author timeline locally") @@ -930,6 +945,9 @@ struct ArchiveTests { live.updatedAt = Date() live.databaseScope = 1 live.ckRecordName = "game-\(original.uuidString)" + live.completedAt = Date(timeIntervalSince1970: 1_700_001_000) + live.latestOtherMoveAt = Date(timeIntervalSince1970: 1_700_000_900) + live.readThroughAt = Date(timeIntervalSince1970: 1_700_000_800) for (authorID, name) in [("alice", "Alice"), ("bob", "Bob")] { let player = PlayerEntity(context: ctx) player.game = live @@ -974,10 +992,25 @@ struct ArchiveTests { #expect(liveSummary.id != archiveSummary.id) #expect(liveSummary.listID == original) #expect(archiveSummary.listID == original) + #expect(liveSummary.hasUnreadOtherMoves) + #expect(archiveSummary.hasUnreadOtherMoves) #expect( liveSummary.allParticipants.first { $0.authorID == "bob" }?.color == archiveSummary.allParticipants.first { $0.authorID == "bob" }?.color ) + + // Both persisted rows describe one unread game, and opening the + // visible Chronicle clears the canonical live state plus its + // projection rather than addressing only the derived archive UUID. + let store = makeTestStore(persistence: persistence) + #expect(store.canonicalGameID(for: archive.id!) == original) + #expect(store.unreadOtherMovesGameCount() == 1) + _ = try store.loadGame(id: archive.id!) + let reviewedLive = try #require(GameSummary(entity: live)) + let reviewedArchive = try #require(GameSummary(entity: archive)) + #expect(!reviewedLive.hasUnreadOtherMoves) + #expect(!reviewedArchive.hasUnreadOtherMoves) + #expect(store.unreadOtherMovesGameCount() == 0) } @Test("block reconciliation preserves legacy Chronicle replacement") diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift @@ -229,6 +229,56 @@ struct PlayerRecordPresenceTests { #expect(row.updatedAt == Date(timeIntervalSince1970: 20)) } + @Test("A sibling read watermark clears the visible Chronicle projection") + func siblingReadThroughMirrorsToChronicle() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let game = try makeGame(in: ctx) + let latest = Date(timeIntervalSince1970: 12) + game.latestOtherMoveAt = latest + try makeExistingLocalPlayer( + in: ctx, + game: game, + updatedAt: Date(timeIntervalSince1970: 10) + ) + + let chronicle = GameEntity(context: ctx) + chronicle.id = Archive.archiveGameID(for: gameID) + chronicle.ckRecordName = Archive.recordName(forOriginalGameID: gameID) + chronicle.title = "Presence Test Chronicle" + chronicle.puzzleSource = "" + chronicle.createdAt = Date(timeIntervalSince1970: 1) + chronicle.updatedAt = Date(timeIntervalSince1970: 1) + chronicle.latestOtherMoveAt = latest + chronicle.readThroughAt = Date(timeIntervalSince1970: 5) + try ctx.save() + + let record = RecordSerializer.playerRecord( + gameID: gameID, + authorID: localAuthorID, + name: "Local", + updatedAt: Date(timeIntervalSince1970: 20), + selection: nil, + presenceUntil: Date(timeIntervalSince1970: 20), + readThrough: latest, + zone: zoneID, + systemFields: nil + ) + + let engine = makeEngine(persistence: persistence) + engine.applyPlayerRecord( + record, + in: ctx, + localAuthorID: localAuthorID, + onFirstTime: { _ in }, + onPresenceChange: { _ in }, + onReadCursor: { _, _, _ in } + ) + + #expect(game.readThroughAt == latest) + #expect(chronicle.readThroughAt == latest) + } + @Test("Partial player record without name still applies other fields") func partialPlayerRecordWithoutNameStillAppliesFields() throws { let persistence = makeTestPersistence()