crossmate

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

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 }