crossmate

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

TimeLog.swift (10161B)


      1 import Foundation
      2 
      3 /// Per-device record of active solving time for one player in one game, encoded
      4 /// into the `Player` record's `timeLog` field.
      5 ///
      6 /// The displayed game clock is the length of the **union** of every player's
      7 /// intervals across the whole game — simultaneous play counts once, disjoint
      8 /// play sums, never double-counted. An in-progress session (`openStart` set)
      9 /// extrapolates to "now" so the clock ticks live and a co-solver who joins
     10 /// mid-session immediately sees time that already reflects everyone present.
     11 ///
     12 /// Slots are keyed by `deviceID` (not merely per-author) so a single account's
     13 /// two devices never last-writer-wins clobber each other's history: each device
     14 /// only ever mutates its own slot, and a reader unions across them.
     15 struct TimeLog: Codable, Equatable {
     16     var devices: [String: DeviceLog]
     17 
     18     init(devices: [String: DeviceLog] = [:]) {
     19         self.devices = devices
     20     }
     21 
     22     /// One device's contribution: its sealed (disjoint, sorted) intervals plus,
     23     /// while a session is open, the start of that session and the last liveness
     24     /// write. `beatAt` bounds how far a peer extrapolates an `openStart` it can't
     25     /// see close — a force-quit/crash leaves `openStart` set forever, so a stale
     26     /// beat caps the open interval rather than letting it run to infinity.
     27     struct DeviceLog: Codable, Equatable {
     28         var intervals: [Interval]
     29         var openStart: Date?
     30         var beatAt: Date?
     31 
     32         init(intervals: [Interval] = [], openStart: Date? = nil, beatAt: Date? = nil) {
     33             self.intervals = intervals
     34             self.openStart = openStart
     35             self.beatAt = beatAt
     36         }
     37     }
     38 
     39     struct Interval: Codable, Equatable {
     40         var start: Date
     41         var end: Date
     42     }
     43 
     44     // MARK: - Config
     45 
     46     /// How long after a peer's last `beatAt` its still-open session keeps
     47     /// extrapolating to "now". Sized to comfortably exceed the heartbeat cadence
     48     /// (`SessionCoordinator.clockHeartbeatInterval`) plus sync propagation, so a
     49     /// live peer mid-session is never prematurely cut off, while a crashed one
     50     /// stops accruing within a few minutes.
     51     static let openGrace: TimeInterval = 4 * 60
     52 
     53     /// Hard ceiling on a single open session. A never-sealed session (the app was
     54     /// killed without a clean leave) can't inflate the clock past this.
     55     static let maxSessionCap: TimeInterval = 6 * 60 * 60
     56 
     57     // MARK: - Single-device mutation (local writer only)
     58 
     59     /// Opens a session for `deviceID`. Idempotent across resumes within one app
     60     /// run — only the first call of a sitting stamps `openStart`; later ones just
     61     /// refresh the heartbeat.
     62     ///
     63     /// `reconcileStale` is set on the first open of a game since app launch. A
     64     /// session still open at that point was never sealed — the previous run was
     65     /// force-quit or crashed — so it is banked bounded at its last heartbeat
     66     /// (mirroring how a peer bounds it) rather than letting the dead gap until
     67     /// `now` count, then a fresh session starts. Within a run it is left false, so
     68     /// an ordinary resume keeps the continuing sitting intact.
     69     mutating func open(deviceID: String, at now: Date, reconcileStale: Bool = false) {
     70         var slot = devices[deviceID] ?? DeviceLog()
     71         if let openStart = slot.openStart {
     72             if reconcileStale {
     73                 let boundedEnd = min(
     74                     (slot.beatAt ?? openStart).addingTimeInterval(Self.openGrace),
     75                     openStart.addingTimeInterval(Self.maxSessionCap),
     76                     now
     77                 )
     78                 if boundedEnd > openStart {
     79                     slot.intervals = Self.inserting(
     80                         Interval(start: openStart, end: boundedEnd),
     81                         into: slot.intervals
     82                     )
     83                 }
     84                 slot.openStart = now
     85             }
     86         } else {
     87             slot.openStart = now
     88         }
     89         slot.beatAt = now
     90         devices[deviceID] = slot
     91     }
     92 
     93     /// Seals `deviceID`'s open session into a sealed interval and clears the open
     94     /// marker. No-op (but still beats) if nothing is open.
     95     mutating func seal(deviceID: String, at now: Date) {
     96         var slot = devices[deviceID] ?? DeviceLog()
     97         if let start = slot.openStart, now > start {
     98             slot.intervals = Self.inserting(Interval(start: start, end: now), into: slot.intervals)
     99         }
    100         slot.openStart = nil
    101         slot.beatAt = now
    102         devices[deviceID] = slot
    103     }
    104 
    105     /// Refreshes `deviceID`'s liveness heartbeat. A no-op unless a session is
    106     /// open — a heartbeat only means "I'm still in my current sitting," so it
    107     /// neither starts a session nor creates a bare slot.
    108     mutating func beat(deviceID: String, at now: Date) {
    109         guard var slot = devices[deviceID], slot.openStart != nil else { return }
    110         slot.beatAt = max(slot.beatAt ?? now, now)
    111         devices[deviceID] = slot
    112     }
    113 
    114     /// Adopts another author's whole device map. Used when applying an inbound
    115     /// record for a *different* author (no local slots to protect).
    116     mutating func adoptAll(from other: TimeLog) {
    117         devices = other.devices
    118     }
    119 
    120     /// Merges an inbound copy of the *local* author's record: adopts every device
    121     /// slot except this device's own, which the local writer owns and must not
    122     /// have clobbered by a sibling's stale copy.
    123     mutating func merge(inbound: TimeLog, preservingDevice deviceID: String) {
    124         var merged = inbound.devices
    125         if let mine = devices[deviceID] {
    126             merged[deviceID] = mine
    127         }
    128         devices = merged
    129     }
    130 
    131     // MARK: - Accumulation (union across all players)
    132 
    133     /// Total active solving time across `logs` (every player's record for the
    134     /// game) as of `now`: the length of the union of all sealed intervals plus
    135     /// each open session extrapolated to a bounded end. `localDeviceID`'s own
    136     /// open session is trusted live to `now`; every other open session is capped
    137     /// at its last heartbeat plus `openGrace`. All open sessions are additionally
    138     /// capped at `maxSessionCap` from their start.
    139     static func accumulatedSeconds(
    140         forLogs logs: [TimeLog],
    141         localDeviceID: String,
    142         asOf now: Date = Date()
    143     ) -> TimeInterval {
    144         var intervals: [Interval] = []
    145         for log in logs {
    146             for (deviceID, slot) in log.devices {
    147                 intervals.append(contentsOf: slot.intervals)
    148                 guard let start = slot.openStart else { continue }
    149                 let hardCap = start.addingTimeInterval(maxSessionCap)
    150                 let liveEnd: Date
    151                 if deviceID == localDeviceID {
    152                     liveEnd = now
    153                 } else {
    154                     liveEnd = min(now, (slot.beatAt ?? start).addingTimeInterval(openGrace))
    155                 }
    156                 let end = min(liveEnd, hardCap)
    157                 if end > start {
    158                     intervals.append(Interval(start: start, end: end))
    159                 }
    160             }
    161         }
    162         return unionSeconds(intervals)
    163     }
    164 
    165     /// Length of the union of `intervals` (overlaps merged, then summed).
    166     static func unionSeconds(_ intervals: [Interval]) -> TimeInterval {
    167         let sorted = intervals
    168             .filter { $0.end > $0.start }
    169             .sorted { $0.start < $1.start }
    170         guard let first = sorted.first else { return 0 }
    171         var total: TimeInterval = 0
    172         var curStart = first.start
    173         var curEnd = first.end
    174         for iv in sorted.dropFirst() {
    175             if iv.start <= curEnd {
    176                 curEnd = max(curEnd, iv.end)
    177             } else {
    178                 total += curEnd.timeIntervalSince(curStart)
    179                 curStart = iv.start
    180                 curEnd = iv.end
    181             }
    182         }
    183         total += curEnd.timeIntervalSince(curStart)
    184         return total
    185     }
    186 
    187     /// Inserts `interval` into a device's own (disjoint) interval list, keeping it
    188     /// sorted by start and coalescing any that touch or overlap — defensive, since
    189     /// a single device's sessions don't normally overlap.
    190     private static func inserting(_ interval: Interval, into list: [Interval]) -> [Interval] {
    191         var merged = unionMerged(list + [interval])
    192         merged.sort { $0.start < $1.start }
    193         return merged
    194     }
    195 
    196     /// The merged (overlap-collapsed) intervals themselves, not just their length.
    197     private static func unionMerged(_ intervals: [Interval]) -> [Interval] {
    198         let sorted = intervals
    199             .filter { $0.end > $0.start }
    200             .sorted { $0.start < $1.start }
    201         guard let first = sorted.first else { return [] }
    202         var result: [Interval] = []
    203         var cur = first
    204         for iv in sorted.dropFirst() {
    205             if iv.start <= cur.end {
    206                 cur.end = max(cur.end, iv.end)
    207             } else {
    208                 result.append(cur)
    209                 cur = iv
    210             }
    211         }
    212         result.append(cur)
    213         return result
    214     }
    215 
    216     // MARK: - Codec
    217 
    218     static func encode(_ log: TimeLog) -> Data {
    219         (try? JSONEncoder().encode(log)) ?? Data()
    220     }
    221 
    222     /// Parse-tolerant: a missing/empty/old-format payload decodes to an empty
    223     /// log (contributes zero), so the clock is safe before the schema deploy.
    224     static func decode(_ data: Data?) -> TimeLog {
    225         guard let data, !data.isEmpty,
    226               let log = try? JSONDecoder().decode(TimeLog.self, from: data)
    227         else { return TimeLog() }
    228         return log
    229     }
    230 
    231     // MARK: - Display
    232 
    233     /// A solve duration as `M:SS`, growing to `H:MM:SS` once past an hour.
    234     /// Shared by the live header clock and the finish panel so both read alike.
    235     static func clockString(_ seconds: TimeInterval) -> String {
    236         let total = max(0, Int(seconds))
    237         let hours = total / 3600
    238         let minutes = (total % 3600) / 60
    239         let secs = total % 60
    240         if hours > 0 {
    241             return String(format: "%d:%02d:%02d", hours, minutes, secs)
    242         }
    243         return String(format: "%d:%02d", minutes, secs)
    244     }
    245 }