crossmate

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

commit 2a888eacf43a174f7d4b76e65692f9064da07192
parent 508f13c9f2abe9f1df9652001538a6f0204af52b
Author: Michael Camilleri <[email protected]>
Date:   Tue, 21 Jul 2026 16:52:48 +0900

Keep delayed completions behind the read horizon

A completion notification delivered long after the puzzle finished could
restore the app badge even when the user had already seen the completed
game, leaving no corresponding unread marker in the Game List.

This commit carries completedAt as the optional PushPayload occurredAt
horizon. The notification service records that event time instead of the
later delivery time, so an existing seen watermark keeps stale
completion pushes read while older payloads retain their delivery-time
fallback.

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

Diffstat:
MCrossmate/Services/SessionCoordinator.swift | 9++++++++-
MNotificationService/NotificationService.swift | 6+++++-
MShared/PushPayload.swift | 9+++++++++
MTests/Unit/NotificationStateTests.swift | 14++++++++++++++
MTests/Unit/PushPayloadTests.swift | 12++++++++++++
5 files changed, 48 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -539,7 +539,14 @@ final class SessionCoordinator { let event: PushPayload.Event = resigned ? .resign : .win let addressees = plan.recipients.compactMap { recipient in recipient.pushAddress.map { - PushClient.Addressee(address: $0, payload: PushPayload(event: event, playerName: preferences.name)) + PushClient.Addressee( + address: $0, + payload: PushPayload( + event: event, + playerName: preferences.name, + occurredAt: plan.completedAt + ) + ) } } guard !addressees.isEmpty else { diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -130,7 +130,11 @@ final class NotificationService: UNNotificationServiceExtension { } if let gameID, marksUnread { - BadgeState.markUnread(gameID: gameID) + // Durable completion delivery can be retried (and APNs can deliver + // it) well after the puzzle finished. Preserve the event's real + // horizon so a recipient who already saw the completion has a + // newer seen watermark and does not get a phantom app badge. + BadgeState.markUnread(gameID: gameID, at: payload?.occurredAt ?? Date()) } // While another device of this account is present in the game, deliver // passively: no banner, no sound — the alert drops quietly into diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -34,6 +34,13 @@ struct PushPayload: Codable, Sendable, Equatable { /// for the sender. `nil` from older senders, which the NSE handles by /// falling back to a short author id. var playerName: String? + /// When the represented event actually happened on the sender. This is + /// distinct from APNs delivery time: a durable completion publish may be + /// retried long after the game finished, and APNs may delay it further. + /// The notification service extension uses this horizon so an old + /// completion cannot resurrect a badge after the recipient has already + /// seen the finished game. Optional for compatibility with older senders. + var occurredAt: Date? /// Optional, opaque-to-the-worker diagnostic context attached by the /// sender. Carries the inputs that produced a pause body's counts so a /// recipient can record them (via the NSE) and reconstruct *why* the @@ -47,12 +54,14 @@ struct PushPayload: Codable, Sendable, Equatable { event: Event, puzzleTitle: String? = nil, playerName: String? = nil, + occurredAt: Date? = nil, diagnostics: Diagnostics? = nil ) { self.version = version self.event = event self.puzzleTitle = puzzleTitle self.playerName = playerName + self.occurredAt = occurredAt self.diagnostics = diagnostics } diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -92,6 +92,20 @@ struct NotificationStateTests { #expect(BadgeState.unreadGameIDs() == Set([gameID])) } + @Test("A delayed completion event does not resurrect a badge after it was seen") + func delayedCompletionDoesNotBeatSeen() { + let gameID = UUID() + let completedAt = Date(timeIntervalSince1970: 25_000) + let reviewedAt = completedAt.addingTimeInterval(30) + + BadgeState.markSeen(gameID: gameID, at: reviewedAt) + + // The notification may arrive hours later, but the NSE records its + // durable completion horizon rather than its delivery time. + #expect(BadgeState.markUnread(gameID: gameID, at: completedAt) == 0) + #expect(BadgeState.unreadGameIDs().isEmpty) + } + @Test("Seeding Core Data unread surfaces games but never resurrects a seen one") func seedUnreadRespectsSeenHorizon() { let fresh = UUID() diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -47,6 +47,17 @@ struct PushPayloadTests { #expect(decoded.playerName == "Alice") } + @Test("Event time round-trips through the wire encoding") + func occurredAtRoundTrips() throws { + let occurredAt = Date(timeIntervalSince1970: 1_234_567) + let payload = PushPayload(event: .win, occurredAt: occurredAt) + + let decoded = try roundTrip(payload) + + #expect(decoded == payload) + #expect(decoded.occurredAt == occurredAt) + } + @Test("An older payload without playerName decodes it as nil") func playerNameAbsenceTolerated() throws { let json = #"{"version":1,"event":{"type":"pause","fills":1,"clears":0,"checks":0,"reveals":0}}"# @@ -55,6 +66,7 @@ struct PushPayloadTests { let decoded = try #require(PushPayload.decode(from: encoded)) #expect(decoded.playerName == nil) + #expect(decoded.occurredAt == nil) } @Test("composedBody rebuilds each event's body with the given name")