crossmate

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

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:
MCrossmate/Services/AppServices.swift | 12+++++++++++-
MCrossmate/Services/CloudService.swift | 1+
MShared/NotificationState.swift | 47++++++++++++++++++++++++++++++++++++-----------
MTests/Unit/NotificationStateTests.swift | 17+++++++++++++++++
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) + } }