crossmate

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

TimeLog.swift (10136B)


      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                 )
     77                 if boundedEnd > openStart {
     78                     slot.intervals = Self.inserting(
     79                         Interval(start: openStart, end: boundedEnd),
     80                         into: slot.intervals
     81                     )
     82                 }
     83                 slot.openStart = now
     84             }
     85         } else {
     86             slot.openStart = now
     87         }
     88         slot.beatAt = now
     89         devices[deviceID] = slot
     90     }
     91 
     92     /// Seals `deviceID`'s open session into a sealed interval and clears the open
     93     /// marker. No-op (but still beats) if nothing is open.
     94     mutating func seal(deviceID: String, at now: Date) {
     95         var slot = devices[deviceID] ?? DeviceLog()
     96         if let start = slot.openStart, now > start {
     97             slot.intervals = Self.inserting(Interval(start: start, end: now), into: slot.intervals)
     98         }
     99         slot.openStart = nil
    100         slot.beatAt = now
    101         devices[deviceID] = slot
    102     }
    103 
    104     /// Refreshes `deviceID`'s liveness heartbeat. A no-op unless a session is
    105     /// open — a heartbeat only means "I'm still in my current sitting," so it
    106     /// neither starts a session nor creates a bare slot.
    107     mutating func beat(deviceID: String, at now: Date) {
    108         guard var slot = devices[deviceID], slot.openStart != nil else { return }
    109         slot.beatAt = max(slot.beatAt ?? now, now)
    110         devices[deviceID] = slot
    111     }
    112 
    113     /// Adopts another author's whole device map. Used when applying an inbound
    114     /// record for a *different* author (no local slots to protect).
    115     mutating func adoptAll(from other: TimeLog) {
    116         devices = other.devices
    117     }
    118 
    119     /// Merges an inbound copy of the *local* author's record: adopts every device
    120     /// slot except this device's own, which the local writer owns and must not
    121     /// have clobbered by a sibling's stale copy.
    122     mutating func merge(inbound: TimeLog, preservingDevice deviceID: String) {
    123         var merged = inbound.devices
    124         if let mine = devices[deviceID] {
    125             merged[deviceID] = mine
    126         }
    127         devices = merged
    128     }
    129 
    130     // MARK: - Accumulation (union across all players)
    131 
    132     /// Total active solving time across `logs` (every player's record for the
    133     /// game) as of `now`: the length of the union of all sealed intervals plus
    134     /// each open session extrapolated to a bounded end. `localDeviceID`'s own
    135     /// open session is trusted live to `now`; every other open session is capped
    136     /// at its last heartbeat plus `openGrace`. All open sessions are additionally
    137     /// capped at `maxSessionCap` from their start.
    138     static func accumulatedSeconds(
    139         forLogs logs: [TimeLog],
    140         localDeviceID: String,
    141         asOf now: Date = Date()
    142     ) -> TimeInterval {
    143         var intervals: [Interval] = []
    144         for log in logs {
    145             for (deviceID, slot) in log.devices {
    146                 intervals.append(contentsOf: slot.intervals)
    147                 guard let start = slot.openStart else { continue }
    148                 let hardCap = start.addingTimeInterval(maxSessionCap)
    149                 let liveEnd: Date
    150                 if deviceID == localDeviceID {
    151                     liveEnd = now
    152                 } else {
    153                     liveEnd = min(now, (slot.beatAt ?? start).addingTimeInterval(openGrace))
    154                 }
    155                 let end = min(liveEnd, hardCap)
    156                 if end > start {
    157                     intervals.append(Interval(start: start, end: end))
    158                 }
    159             }
    160         }
    161         return unionSeconds(intervals)
    162     }
    163 
    164     /// Length of the union of `intervals` (overlaps merged, then summed).
    165     static func unionSeconds(_ intervals: [Interval]) -> TimeInterval {
    166         let sorted = intervals
    167             .filter { $0.end > $0.start }
    168             .sorted { $0.start < $1.start }
    169         guard let first = sorted.first else { return 0 }
    170         var total: TimeInterval = 0
    171         var curStart = first.start
    172         var curEnd = first.end
    173         for iv in sorted.dropFirst() {
    174             if iv.start <= curEnd {
    175                 curEnd = max(curEnd, iv.end)
    176             } else {
    177                 total += curEnd.timeIntervalSince(curStart)
    178                 curStart = iv.start
    179                 curEnd = iv.end
    180             }
    181         }
    182         total += curEnd.timeIntervalSince(curStart)
    183         return total
    184     }
    185 
    186     /// Inserts `interval` into a device's own (disjoint) interval list, keeping it
    187     /// sorted by start and coalescing any that touch or overlap — defensive, since
    188     /// a single device's sessions don't normally overlap.
    189     private static func inserting(_ interval: Interval, into list: [Interval]) -> [Interval] {
    190         var merged = unionMerged(list + [interval])
    191         merged.sort { $0.start < $1.start }
    192         return merged
    193     }
    194 
    195     /// The merged (overlap-collapsed) intervals themselves, not just their length.
    196     private static func unionMerged(_ intervals: [Interval]) -> [Interval] {
    197         let sorted = intervals
    198             .filter { $0.end > $0.start }
    199             .sorted { $0.start < $1.start }
    200         guard let first = sorted.first else { return [] }
    201         var result: [Interval] = []
    202         var cur = first
    203         for iv in sorted.dropFirst() {
    204             if iv.start <= cur.end {
    205                 cur.end = max(cur.end, iv.end)
    206             } else {
    207                 result.append(cur)
    208                 cur = iv
    209             }
    210         }
    211         result.append(cur)
    212         return result
    213     }
    214 
    215     // MARK: - Codec
    216 
    217     static func encode(_ log: TimeLog) -> Data {
    218         (try? JSONEncoder().encode(log)) ?? Data()
    219     }
    220 
    221     /// Parse-tolerant: a missing/empty/old-format payload decodes to an empty
    222     /// log (contributes zero), so the clock is safe before the schema deploy.
    223     static func decode(_ data: Data?) -> TimeLog {
    224         guard let data, !data.isEmpty,
    225               let log = try? JSONDecoder().decode(TimeLog.self, from: data)
    226         else { return TimeLog() }
    227         return log
    228     }
    229 
    230     // MARK: - Display
    231 
    232     /// A solve duration as `M:SS`, growing to `H:MM:SS` once past an hour.
    233     /// Shared by the live header clock and the finish panel so both read alike.
    234     static func clockString(_ seconds: TimeInterval) -> String {
    235         let total = max(0, Int(seconds))
    236         let hours = total / 3600
    237         let minutes = (total % 3600) / 60
    238         let secs = total % 60
    239         if hours > 0 {
    240             return String(format: "%d:%02d:%02d", hours, minutes, secs)
    241         }
    242         return String(format: "%d:%02d", minutes, secs)
    243     }
    244 }