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:
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()