commit b467735e62788bb9a811da6a0432f54b1dfa0a67
parent c4f2236fc7d50c327630403eec22624ba37916d4
Author: Michael Camilleri <[email protected]>
Date: Thu, 4 Jun 2026 21:37:35 +0900
Handle deleting games from the notification ledge
Nothing removed a game's entry from the notification ledger when the
game was deleted. A deleted game has nothing left to open, so a stale
unreadAt > seenAt entry would badge forever. BadgeState.forget(gameID:)
is now called on both hard-removal hooks — the local onGameDeleted path
and the sync-driven onGameRemoved path — and BadgeState.reset() clears
the whole ledger on the diagnostics data wipe. Entries are maintained
by lifecycle event rather than reconciled by absence, because a
ledger-only game is ambiguous: it may be a not-yet-synced push that must
still count, not a deletion.
In addition, the ledger key is bumped to badge.ledger.v2 as a one-shot
discard of any polluted store. The loadLedger function now drops the
legacy stores without fabricating timestamps, leaving refreshAppBadge to
rebuild the unread set from Core Data ground truth.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 65 insertions(+), 12 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -258,6 +258,12 @@ final class AppServices {
}
},
onGameDeleted: { [preferences] deletion in
+ // Drop the badge ledger entry regardless of sync state — a
+ // deleted game has nothing left to open, so a stale unread
+ // horizon would count forever. `deleteGame` fires
+ // `onUnreadOtherMovesChanged` right after, which refreshes the
+ // app badge.
+ BadgeState.forget(gameID: deletion.gameID)
guard preferences.isICloudSyncEnabled else { return }
onGameDeletedHandler(deletion)
},
@@ -528,9 +534,13 @@ final class AppServices {
announcements.post(.accessRevoked(gameID: gameID))
}
- await syncEngine.setOnGameRemoved { [store, sessionMonitor, announcements] gameID in
+ await syncEngine.setOnGameRemoved { [weak self, store, sessionMonitor, announcements] gameID in
let wasOpen = store.handleRemoteRemoval(gameID: gameID)
sessionMonitor.clearMovesSnapshots(for: gameID, by: nil)
+ // The local row is gone, so drop its badge ledger entry: a seen
+ // horizon can't clear it once there's no game left to open.
+ BadgeState.forget(gameID: gameID)
+ await self?.refreshAppBadge()
// A hard-deleted game (private zone gone, or a shared game left
// elsewhere) only needs UI when its puzzle is on screen: a sticky,
// input-blocking banner freezes the now-orphaned puzzle until the
diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift
@@ -105,6 +105,7 @@ final class CloudService {
try store.resetAllData()
UserDefaults.standard.removeObject(forKey: "gamePlayerColors")
+ BadgeState.reset()
syncMonitor.note("Database reset — all games and sync state cleared")
}
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -188,7 +188,8 @@ enum NotificationState {
/// `unreadAt > seenAt`, so opening a puzzle can defeat stale NSE entries rather
/// than fighting the old "union forever" set semantics.
enum BadgeState {
- private static let ledgerKey = "badge.ledger.v1"
+ private static let ledgerKey = "badge.ledger.v2"
+ private static let legacyLedgerKey = "badge.ledger.v1"
private static let legacyUnreadKey = "badge.unreadGameIDs"
private struct Entry: Codable, Equatable {
@@ -257,23 +258,47 @@ enum BadgeState {
saveLedger(ledger)
}
+ /// Removes a game from the ledger outright. Called when a game is deleted
+ /// or hard-removed: a seen horizon can't help once the game is gone (there
+ /// is nothing left to open), so a stale `unreadAt > seenAt` entry would
+ /// otherwise count toward the badge forever.
+ static func forget(gameID: UUID) {
+ var ledger = loadLedger()
+ guard ledger.removeValue(forKey: gameID.uuidString) != nil else { return }
+ saveLedger(ledger)
+ }
+
+ /// Clears the entire ledger (and any legacy stores). Used by the
+ /// diagnostics "reset all data" path, which deletes every game at once.
+ static func reset() {
+ guard let defaults else { return }
+ defaults.removeObject(forKey: ledgerKey)
+ defaults.removeObject(forKey: legacyLedgerKey)
+ defaults.removeObject(forKey: legacyUnreadKey)
+ }
+
private static func loadLedger() -> [String: Entry] {
guard let defaults else { return [:] }
if let data = defaults.data(forKey: ledgerKey),
let ledger = try? JSONDecoder().decode([String: Entry].self, from: data) {
return ledger
}
- guard let raw = defaults.array(forKey: legacyUnreadKey) as? [String] else {
- return [:]
+ // Neither the pre-horizon set (`badge.unreadGameIDs`) nor the v1 ledger
+ // carried trustworthy unread timestamps we can rebuild from: the v1
+ // migration fabricated `unreadAt = now` for every legacy entry, which
+ // makes an already-seen game look freshly unread and, worse,
+ // un-clearable by read state — a future-dated `unreadAt` beats any past
+ // `seenAt`, and a deleted game has nothing left to open. Core Data is
+ // the durable ground truth for unread-other-moves, so discard both
+ // legacy stores and let `refreshAppBadge` re-seed from Core Data on the
+ // next run rather than carry phantom badges forward.
+ if defaults.object(forKey: legacyLedgerKey) != nil {
+ defaults.removeObject(forKey: legacyLedgerKey)
}
- let now = Date()
- let migrated = Dictionary(uniqueKeysWithValues: raw.compactMap { rawID -> (String, Entry)? in
- guard UUID(uuidString: rawID) != nil else { return nil }
- return (rawID, Entry(unreadAt: now, seenAt: nil))
- })
- saveLedger(migrated)
- defaults.removeObject(forKey: legacyUnreadKey)
- return migrated
+ if defaults.object(forKey: legacyUnreadKey) != nil {
+ defaults.removeObject(forKey: legacyUnreadKey)
+ }
+ return [:]
}
private static func saveLedger(_ ledger: [String: Entry]) {
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -109,4 +109,21 @@ struct NotificationStateTests {
#expect(BadgeState.unreadGameIDs() == Set([fresh]))
}
+
+ @Test("Forgetting a game drops its ledger entry so a deleted game can't badge")
+ func forgetRemovesLedgerEntry() {
+ let gameID = UUID()
+ let at = Date(timeIntervalSince1970: 40_000)
+
+ #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1)
+ #expect(BadgeState.unreadGameIDs() == Set([gameID]))
+
+ // A deleted game can never be opened to advance `seenAt`, so the entry
+ // must be removed outright rather than just marked seen.
+ BadgeState.forget(gameID: gameID)
+ #expect(BadgeState.unreadGameIDs().isEmpty)
+
+ // A re-seed of a forgotten game (e.g. a stale push) starts clean.
+ #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1)
+ }
}