crossmate

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

commit a29c0bf6e330c106992664e4265766459c322ac2
parent 759dc2e4f9c78c356a774f54c57e583b0bc4693b
Author: Michael Camilleri <[email protected]>
Date:   Wed, 10 Jun 2026 12:57:20 +0900

Split the badge ledger's seen horizon from its suppression horizon

The push-side badge ledger (BadgeState) retained the same conflation the
read-watermark split removed from the Player record: every dismissal and
accountSeen path wrote a forward-dated horizon (now + the 10-minute
lease) straight into the entry's monotonic seenAt. While the user is
looking that suppresses badging correctly, but nothing can pull a
monotonic value back, so after the user left the puzzle the Notification
Service Extension kept swallowing the app-icon badge for the rest of the
lease window. A peer's 'stopped solving' push landing minutes after the
user backgrounded showed its banner but never incremented the badge --
the suspended-app half of the symptom the watermark split fixed for Core
Data.

This commit gives each ledger entry a second horizon, suppressedUntil,
and confines seenAt to a true watermark that never moves into the
future:

  - A ledger entry is unread only when unreadAt is newer than both
    seenAt and suppressedUntil, so the NSE's count respects suppression
    without consulting any other state.
  - adoptReadHorizon splits a possibly forward-dated horizon: the
    watermark advances to min(horizon, now), the suppression takes the
    full value. dismissDeliveredNotifications and the lease-mint path in
    publishReadCursor route through it; the accountSeen wire format is
    unchanged.
  - extendSuppression is monotonic, so a stale lease arriving out of
    order cannot shorten an active one; collapseSuppression adopts the
    close instant directly -- the operation the old single-field design
    could not express.
  - The .currentTime leave path collapses the suppression in lockstep
    with the lease and watermark. The write is local App Group state, so
    it lands even when the CloudKit lease collapse does not.
  - An inbound sibling close collapses this device's suppression too,
    unless the puzzle is on screen here, where the local lease governs.

A push that arrived after the user actually left now resurrects as
unread when the collapse lands, while one the user watched live stays
cleared behind the leave-time watermark. The new field is optional, so
ledgers written before this change decode unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 38+++++++++++++++++++++++++++++++++++---
MShared/NotificationState.swift | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
MTests/Unit/NotificationStateTests.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 188 insertions(+), 9 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -519,11 +519,24 @@ final class AppServices { // `readLeaseRefreshFloor` (5 min) for the next renewal tick. // `requireActivePuzzle` re-checks inside the write so a leave // racing this inbound can't strand a stale future lease. + // The local badge ledger keeps this device's own suppression + // horizon — a sibling's close doesn't mean *we* stopped + // looking. await self?.publishReadCursor( for: gameID, mode: .activeLease, requireActivePuzzle: true ) + } else { + // Sibling closed its session and nothing is on screen here: + // the account stopped looking at `readAt`. Pull the badge + // ledger's suppression horizon back to that instant (and + // advance the watermark to it) so a push arriving after the + // close badges here instead of staying swallowed under the + // sibling's old lease, which this device adopted when the + // lease was minted. + BadgeState.markSeen(gameID: gameID, at: readAt) + BadgeState.collapseSuppression(gameID: gameID, to: readAt) } } } @@ -3237,14 +3250,21 @@ final class AppServices { center.removeDeliveredNotifications(withIdentifiers: identifiers) syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)") } - let seenAt = explicitSeenAt + // While viewing, the horizon is the local lease (forward-dated); the + // ledger adoption splits it — watermark to now, suppression to the + // lease — so pushes landing mid-session don't badge, yet the leave + // path's `collapseSuppression` can still un-swallow anything that + // arrives after the user stops looking. Writing the raw horizon into + // `markSeen` here is what used to pin the badge dark for the rest of + // the lease window after backgrounding. + let horizon = explicitSeenAt ?? (NotificationState.isSuppressed(gameID: gameID) ? Date().addingTimeInterval(Self.readLeaseDuration) : Date()) - BadgeState.markSeen(gameID: gameID, at: seenAt) + BadgeState.adoptReadHorizon(gameID: gameID, horizon: horizon) await refreshAppBadge() if publishAccountSeen { - await publishAccountSeenPush(gameID: gameID, readAt: seenAt) + await publishAccountSeenPush(gameID: gameID, readAt: horizon) } } @@ -3310,6 +3330,11 @@ final class AppServices { "readAt=\(readAt.ISO8601Format()) foreground=\(isAppForeground) " + "suppressed=\(NotificationState.isSuppressed(gameID: gameID))" ) + // Mirror the renewed lease into the badge ledger so an NSE + // push landing mid-session stays suppressed past the open's + // initial horizon (the open stamps one via + // `dismissDeliveredNotifications`; this covers the refreshes). + BadgeState.adoptReadHorizon(gameID: gameID, horizon: readAt) await publishAccountSeenPush(gameID: gameID, readAt: readAt) } case .currentTime: @@ -3320,6 +3345,13 @@ final class AppServices { // saw live just before leaving; it never reaches into the future. let collapsed = store.setReadCursor(gameID: gameID, readAt: now) let advanced = store.advanceReadThrough(gameID: gameID, through: now) + // Collapse the badge ledger's suppression horizon in the same + // lockstep. This write is local (App Group defaults), so it lands + // even when the CloudKit lease collapse doesn't — an NSE push + // arriving a minute after leave badges instead of being swallowed + // for the rest of the lease window. + BadgeState.markSeen(gameID: gameID, at: now) + BadgeState.collapseSuppression(gameID: gameID, to: now) didUpdate = collapsed || advanced } guard didUpdate else { return } diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -183,10 +183,23 @@ enum NotificationState { /// provisional push-side input; Core Data remains the app's synced ground truth /// for Moves-derived unread state. /// -/// Each game tracks the newest push-side unread event and the newest local -/// seen horizon. A game is unread from this ledger only when -/// `unreadAt > seenAt`, so opening a puzzle can defeat stale NSE entries rather -/// than fighting the old "union forever" set semantics. +/// Each game tracks the newest push-side unread event, the newest local seen +/// horizon, and a suppression horizon. A game is unread from this ledger only +/// when `unreadAt` is newer than both, so opening a puzzle can defeat stale NSE +/// entries rather than fighting the old "union forever" set semantics. +/// +/// `seenAt` is a true read watermark — it never moves into the future, and it +/// only advances. `suppressedUntil` is the ledger's mirror of the account's +/// presence lease: while some device of this account is in the puzzle, pushes +/// arriving before the horizon are presumed watched live and don't badge. +/// Unlike `seenAt` it is *collapsible* — leaving the puzzle (or a sibling's +/// session close syncing in) pulls it back to the leave instant, so a push +/// that actually arrived after the user stopped looking resurrects as unread. +/// Folding both meanings into a forward-dated `seenAt`, as before, made the +/// suppression permanent: `markSeen` is monotonic, so the badge swallowed +/// every push for the rest of the lease window even after the user left — +/// the push-ledger twin of the `readAt`/`readThrough` conflation PLAN.md +/// describes. enum BadgeState { private static let ledgerKey = "badge.ledger.v2" private static let legacyLedgerKey = "badge.ledger.v1" @@ -196,6 +209,9 @@ enum BadgeState { private struct Entry: Codable, Equatable { var unreadAt: Date? = nil var seenAt: Date? = nil + /// Optional with a default so ledgers written before the field existed + /// decode as nil (no suppression) rather than failing wholesale. + var suppressedUntil: Date? = nil } private static var defaults: UserDefaults? { @@ -207,7 +223,8 @@ enum BadgeState { return Set(ledger.compactMap { key, entry in guard let gameID = UUID(uuidString: key), let unreadAt = entry.unreadAt, - unreadAt > (entry.seenAt ?? .distantPast) + unreadAt > (entry.seenAt ?? .distantPast), + unreadAt > (entry.suppressedUntil ?? .distantPast) else { return nil } return gameID }) @@ -228,7 +245,11 @@ enum BadgeState { } /// Records that the user has seen this game on this device. Returns the - /// resulting ledger-only unread count. + /// resulting ledger-only unread count. `time` must not be forward-dated: + /// `seenAt` is monotonic, so a future value would suppress unread events + /// irreversibly — that's `suppressedUntil`'s (collapsible) job. Callers + /// holding a horizon that may reach into the future go through + /// `adoptReadHorizon`, which splits it. @discardableResult static func markSeen(gameID: UUID, at time: Date = Date()) -> Int { var ledger = loadLedger() @@ -241,6 +262,46 @@ enum BadgeState { return unreadGameIDs().count } + /// Records an account read horizon that may be forward-dated (a presence + /// lease, locally minted or received from a sibling device): the watermark + /// advances only to `min(horizon, now)`, while the suppression horizon + /// takes the full value. The two halves mirror the `readThrough`/`readAt` + /// split on the Player record. + static func adoptReadHorizon(gameID: UUID, horizon: Date, now: Date = Date()) { + markSeen(gameID: gameID, at: min(horizon, now)) + extendSuppression(gameID: gameID, until: horizon) + } + + /// Raises the suppression horizon for `gameID`, monotonically — a renewal + /// extends it, while a stale (older) horizon arriving late can't shorten + /// an active one. The deliberate pull-back on session close goes through + /// `collapseSuppression`. Mirrors how the presence lease itself behaves + /// (`GameStore.setReadCursor`'s refresh floor vs. its direct collapse). + static func extendSuppression(gameID: UUID, until horizon: Date) { + var ledger = loadLedger() + var entry = ledger[gameID.uuidString] ?? Entry() + guard (entry.suppressedUntil ?? .distantPast) < horizon else { return } + entry.suppressedUntil = horizon + ledger[gameID.uuidString] = entry + saveLedger(ledger) + } + + /// Collapses the suppression horizon to `horizon` — the session-close + /// signal (this device leaving the puzzle, or a sibling's close syncing + /// in). Direct adoption, not monotonic: the whole point is to pull a + /// forward-dated lease back to the instant the account stopped looking, + /// so a push that arrived after that instant counts as unread again. A + /// game with no ledger entry has nothing to collapse. + static func collapseSuppression(gameID: UUID, to horizon: Date) { + var ledger = loadLedger() + guard var entry = ledger[gameID.uuidString], + entry.suppressedUntil != horizon + else { return } + entry.suppressedUntil = horizon + ledger[gameID.uuidString] = entry + saveLedger(ledger) + } + /// Bulk-applies push-side unread horizons in a single load/save — one /// `markUnread`-equivalent per entry. The app uses this to seed Core Data /// ground truth into the ledger so the NSE inherits it while the app is diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -127,6 +127,92 @@ struct NotificationStateTests { #expect(BadgeState.markUnread(gameID: gameID, at: at) == 1) } + @Test("A push during the suppression horizon badges once the leave collapse lands") + func suppressionCollapseResurrectsPostLeavePush() { + let gameID = UUID() + let open = Date(timeIntervalSince1970: 60_000) + let lease = open.addingTimeInterval(600) + let leave = open.addingTimeInterval(120) + let push = open.addingTimeInterval(300) + + // Opening the puzzle adopts the forward-dated lease: watermark stays + // at `now`, suppression takes the full horizon. + BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: open) + + // A push landing while the lease horizon covers it is presumed watched. + #expect(BadgeState.markUnread(gameID: gameID, at: push) == 0) + #expect(BadgeState.unreadGameIDs().isEmpty) + + // The PLAN.md scenario: the user left at `leave`, before the push + // arrived. Collapsing the suppression to the leave instant resurrects + // it — under the old forward-dated `seenAt` it stayed swallowed until + // the lease ran out. + BadgeState.markSeen(gameID: gameID, at: leave) + BadgeState.collapseSuppression(gameID: gameID, to: leave) + #expect(BadgeState.unreadGameIDs() == Set([gameID])) + } + + @Test("A push the user actually watched stays cleared after the collapse") + func suppressionCollapseKeepsWatchedPushCleared() { + let gameID = UUID() + let open = Date(timeIntervalSince1970: 70_000) + let lease = open.addingTimeInterval(600) + let push = open.addingTimeInterval(60) + let leave = open.addingTimeInterval(120) + + BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: open) + #expect(BadgeState.markUnread(gameID: gameID, at: push) == 0) + + // The push arrived before the user left, so the leave-time watermark + // covers it: collapsing the suppression must not bring it back. + BadgeState.markSeen(gameID: gameID, at: leave) + BadgeState.collapseSuppression(gameID: gameID, to: leave) + #expect(BadgeState.unreadGameIDs().isEmpty) + } + + @Test("adoptReadHorizon never forward-dates the seen watermark") + func adoptReadHorizonClampsWatermark() { + let gameID = UUID() + let now = Date(timeIntervalSince1970: 80_000) + let lease = now.addingTimeInterval(600) + + BadgeState.adoptReadHorizon(gameID: gameID, horizon: lease, now: now) + + // With the suppression out of the way, only the (clamped) watermark + // remains — a push after `now` must count as unread. A forward-dated + // watermark here is the irreversible-swallow bug. + BadgeState.collapseSuppression(gameID: gameID, to: now) + #expect(BadgeState.markUnread(gameID: gameID, at: now.addingTimeInterval(10)) == 1) + #expect(BadgeState.unreadGameIDs() == Set([gameID])) + } + + @Test("A stale lease arriving late cannot shorten an extended suppression") + func extendSuppressionIsMonotonic() { + let gameID = UUID() + let base = Date(timeIntervalSince1970: 90_000) + + BadgeState.extendSuppression(gameID: gameID, until: base.addingTimeInterval(600)) + // An older horizon (e.g. an out-of-order accountSeen) must not pull + // the active one back — only an explicit collapse may do that. + BadgeState.extendSuppression(gameID: gameID, until: base.addingTimeInterval(300)) + + #expect(BadgeState.markUnread(gameID: gameID, at: base.addingTimeInterval(400)) == 0) + #expect(BadgeState.unreadGameIDs().isEmpty) + + BadgeState.collapseSuppression(gameID: gameID, to: base.addingTimeInterval(300)) + #expect(BadgeState.unreadGameIDs() == Set([gameID])) + } + + @Test("Collapsing suppression for an unknown game leaves the ledger clean") + func collapseSuppressionWithoutEntryIsNoOp() { + let gameID = UUID() + BadgeState.collapseSuppression(gameID: gameID, to: Date(timeIntervalSince1970: 95_000)) + #expect(BadgeState.unreadGameIDs().isEmpty) + + // The game still badges normally afterwards. + #expect(BadgeState.markUnread(gameID: gameID, at: Date(timeIntervalSince1970: 95_100)) == 1) + } + @Test("Pending invites round-trip and overwrite wholesale") func pendingInvitesOverwrite() { let first = UUID()