GameViewedStore.swift (2873B)
1 import Foundation 2 3 /// Persists, per game, the wall-clock time this player last viewed the puzzle 4 /// in `UserDefaults`. 5 /// 6 /// Device-local by design — it is never synced to CloudKit. It records when 7 /// *this* player last had the board open so that, on reopening, cells a peer 8 /// filled or cleared while they were away can be highlighted. This is unrelated 9 /// to the synced `Player.readAt`/`notifiedThrough` notification bookkeeping; it 10 /// is purely local viewing state, like `GameCursorStore`. 11 /// 12 /// Layout under the `"gameLastViewed"` key: 13 /// `[gameID.uuidString: TimeInterval]` 14 /// where the value is `Date.timeIntervalSinceReferenceDate`. 15 @MainActor 16 final class GameViewedStore { 17 private let defaults: UserDefaults 18 private let defaultsKey = "gameLastViewed" 19 20 init(defaults: UserDefaults = .standard) { 21 self.defaults = defaults 22 } 23 24 // MARK: - Read 25 26 /// When this player last viewed the game, or `nil` if they never have — 27 /// the first open establishes the baseline rather than flagging the whole 28 /// board as unseen. 29 func lastViewed(forGame gameID: UUID) -> Date? { 30 guard let interval = rawStore[gameID.uuidString] else { return nil } 31 return Date(timeIntervalSinceReferenceDate: interval) 32 } 33 34 // MARK: - Write 35 36 /// Moves the baseline for `gameID` forward to `date`, never backward. The 37 /// "last viewed" cutoff is monotonic: an older stamp (a laggy leave, or a 38 /// stale value adopted from a sibling) must not regress it, or already-seen 39 /// changes would re-surface as borders and a banner on the next open. 40 func advance(_ date: Date, forGame gameID: UUID) { 41 let interval = date.timeIntervalSinceReferenceDate 42 var s = rawStore 43 if let existing = s[gameID.uuidString], existing >= interval { return } 44 s[gameID.uuidString] = interval 45 rawStore = s 46 } 47 48 func clearLastViewed(forGame gameID: UUID) { 49 var s = rawStore 50 guard s[gameID.uuidString] != nil else { return } 51 s.removeValue(forKey: gameID.uuidString) 52 rawStore = s 53 } 54 55 // MARK: - Private 56 57 private var rawStore: [String: TimeInterval] { 58 get { defaults.dictionary(forKey: defaultsKey) as? [String: TimeInterval] ?? [:] } 59 set { defaults.set(newValue, forKey: defaultsKey) } 60 } 61 } 62 63 /// The device-local "last viewed" cutoff, shipped across the account's own 64 /// devices on `Player.sessionSnapshot` so a sibling converges on the latest 65 /// view time rather than recomputing from its own (possibly stale) view. Adopted 66 /// monotonically via `GameViewedStore.advance`. Encoded with the default 67 /// `JSONEncoder`, so an older per-peer-snapshot payload from a not-yet-upgraded 68 /// sibling simply fails to decode and is ignored (per-device fallback). 69 struct SeenBaseline: Codable, Equatable { 70 let viewedAt: Date 71 }