import Foundation struct Block: Hashable { let start: Date let end: Date let srcStableID: String? // stable source item ID for reschedule tracking let label: String? // source title (for dry-run / non-private) let notes: String? // source notes (for optional copy) let occurrence: Date? // occurrenceDate for recurring instances /// Convenience factory for time-only blocks (used internally for occupancy tracking). static func span(start: Date, end: Date) -> Block { Block(start: start, end: end, srcStableID: nil, label: nil, notes: nil, occurrence: nil) } } // De-dup blocks by occurrence (preferred) or by time range func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] { var seen = Set() var out: [Block] = [] for b in blocks { let key: String if trackByID, let sid = b.srcStableID { let occ = b.occurrence.map { String($0.timeIntervalSince1970) } ?? "-" key = "id|\(sid)|\(occ)" } else { key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)" } if seen.insert(key).inserted { out.append(b) } } return out } func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] { guard !blocks.isEmpty else { return [] } let sorted = blocks.sorted { $0.start < $1.start } var out: [Block] = [] var cur = Block.span(start: sorted[0].start, end: sorted[0].end) for b in sorted.dropFirst() { let gap = b.start.timeIntervalSince(cur.end) / 60.0 if gap <= Double(gapMinutes) { if b.end > cur.end { cur = Block.span(start: cur.start, end: b.end) } } else { out.append(cur) cur = Block.span(start: b.start, end: b.end) } } out.append(cur) return out } func coalesce(_ segs: [Block]) -> [Block] { mergeBlocks(segs, gapMinutes: 0) } func fullyCovered(_ mergedSegs: [Block], block: Block, tolMin: Double) -> Bool { for s in mergedSegs { if s.start <= block.start.addingTimeInterval(tolMin * 60), s.end >= block.end.addingTimeInterval(-tolMin * 60) { return true } } return false } func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] { if mergedSegs.isEmpty { return [Block.span(start: block.start, end: block.end)] } var segs: [Block] = [] for s in mergedSegs where s.end > block.start && s.start < block.end { let ss = max(s.start, block.start) let ee = min(s.end, block.end) if ee > ss { segs.append(Block.span(start: ss, end: ee)) } } if segs.isEmpty { return [Block.span(start: block.start, end: block.end)] } let merged = coalesce(segs) var gaps: [Block] = [] var prevEnd = block.start for s in merged { if s.start > prevEnd { gaps.append(Block.span(start: prevEnd, end: s.start)) } if s.end > prevEnd { prevEnd = s.end } } if prevEnd < block.end { gaps.append(Block.span(start: prevEnd, end: block.end)) } return gaps }