crossmate

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

commit d262e0f16d5542be8b852c75d37e20a3aa195f74
parent 48f53a82c081df468ef64e0ee18f692d6c2a656d
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 18:35:04 +0900

Fixed app badging on legacy puzzles

Diffstat:
MCrossmate/Persistence/GameStore.swift | 34++++++++++++++++++++++++----------
MTests/Unit/GameStoreUnreadMovesTests.swift | 39+++++++++++++++++++++++++++++++++++++++
2 files changed, 63 insertions(+), 10 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -156,10 +156,11 @@ struct GameSummary: Identifiable, Equatable { isShared: self.isShared, latest: entity.latestOtherMoveAt, // The unread badge keys off the read *watermark*, not the presence - // lease. The lease (`lastReadOtherMoveAt`) is forward-dated while - // present, which used to suppress the badge for ~10 min after the - // user backgrounded — swallowing moves that arrived in that window. - readThrough: entity.readThroughAt + // lease. Rows created before the watermark existed only have the + // older cursor, so use a non-future legacy value as a migration + // fallback until the first real readThroughAt write lands. + readThrough: entity.readThroughAt, + legacyReadAt: entity.lastReadOtherMoveAt ) } @@ -171,11 +172,15 @@ struct GameSummary: Identifiable, Equatable { fileprivate static func computeHasUnread( isShared: Bool, latest: Date?, - readThrough: Date? + readThrough: Date?, + legacyReadAt: Date? ) -> Bool { guard isShared, let latest else { return false } - guard let readThrough else { return true } - return latest > readThrough + if let readThrough { + return latest > readThrough + } + guard let legacyReadAt, legacyReadAt <= Date() else { return true } + return latest > legacyReadAt } private static func computeParticipants( @@ -839,12 +844,21 @@ final class GameStore { private var unreadOtherMovesPredicate: NSPredicate { // Keyed off the read *watermark* (`readThroughAt`), not the forward- // dated presence lease (`lastReadOtherMoveAt`) — matches - // `GameSummary.computeHasUnread`. Using the lease here suppressed the - // badge for moves that arrived after the user backgrounded. + // `GameSummary.computeHasUnread`. For pre-watermark rows, fall back to + // a non-future legacy cursor so an upgrade doesn't badge every + // already-seen shared puzzle whose readThroughAt starts nil. Future + // legacy values are active-session leases, not durable read watermarks. NSPredicate( format: "(databaseScope == 1 OR ckShareRecordName != nil) " + "AND latestOtherMoveAt != nil " - + "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)" + + "AND (" + + " (readThroughAt != nil AND latestOtherMoveAt > readThroughAt) " + + " OR (readThroughAt == nil AND " + + " (lastReadOtherMoveAt == nil " + + " OR lastReadOtherMoveAt > %@ " + + " OR latestOtherMoveAt > lastReadOtherMoveAt))" + + ")", + Date() as NSDate ) } diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -383,6 +383,45 @@ struct GameStoreUnreadMovesTests { #expect(!store.hasUnreadOtherMoves(gameID: gameID)) } + @Test("Legacy read cursor backs pre-watermark rows") + func legacyReadCursorBacksPreWatermarkRows() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try makeSharedGame(in: ctx) + + let latest = Date(timeIntervalSinceNow: -60) + entity.latestOtherMoveAt = latest + entity.lastReadOtherMoveAt = latest + entity.readThroughAt = nil + try ctx.save() + + let summary = try #require(GameSummary(entity: entity)) + #expect(!summary.hasUnreadOtherMoves) + #expect(store.unreadOtherMovesGameCount() == 0) + #expect(!store.hasUnreadOtherMoves(gameID: gameID)) + } + + @Test("Read watermark overrides a newer legacy presence lease") + func readWatermarkOverridesNewerLegacyPresenceLease() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let (entity, gameID) = try makeSharedGame(in: ctx) + + let readThrough = Date(timeIntervalSinceNow: -120) + let latest = Date(timeIntervalSinceNow: -60) + entity.latestOtherMoveAt = latest + entity.readThroughAt = readThrough + entity.lastReadOtherMoveAt = Date(timeIntervalSinceNow: 10 * 60) + try ctx.save() + + let summary = try #require(GameSummary(entity: entity)) + #expect(summary.hasUnreadOtherMoves) + #expect(store.unreadOtherMovesGameCount() == 1) + #expect(store.hasUnreadOtherMoves(gameID: gameID)) + } + @Test("Active read leases refresh only when the horizon is below the floor") func activeReadLeaseRefreshesAtFloor() throws { let persistence = makeTestPersistence()