Files
busymirror/BusyMirror/BlockMath.swift
T
tomas.kracmarandClaude Sonnet 5 61cea918a6 Release 1.6.1
- Sync reminders: new "Sync reminders when mirroring" option (global and
  per-route) copies source event alarms into mirrored placeholders. CLI flag
  --sync-reminders.
- CLI polish: --help, --list-calendars, --status (all support --json).
  Scheduled/headless runs record last-run time/result/summary. Failure paths
  now exit with distinct nonzero codes (2 = no calendar access, 3 = no saved
  routes) instead of always exiting 0.
- Fix settings silently wiped on upgrade: SettingsPayload's auto-synthesized
  Codable threw on any settings blob missing a field added since (e.g. the
  new syncReminders key), failing the whole decode and letting the next
  autosave persist an empty state over real routes/filters. Custom decode
  now falls back per-field like Route already did, and loadSettingsFromDefaults
  recovers routes from the legacy routes.v1 key if settings.v2 comes back
  empty, repairing installs already hit by this.
- Roadmap: event-driven background sync (SMAppService + EKEventStoreChanged +
  wake notification) and an external MCP wrapper around the CLI queued next;
  decided against direct Google/CalDAV API integration since EventKit already
  covers it via System Settings accounts.

All 47 unit tests pass (45 existing + 2 new SettingsPayloadTests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 13:10:23 +02:00

104 lines
4.0 KiB
Swift

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
let alarmOffsets: [TimeInterval]? // relative alarm offsets copied from source event
/// 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, alarmOffsets: nil)
}
// Alarms are carried along for mirroring but do not affect time-based
// deduplication, merging, or overlap calculations.
static func == (lhs: Block, rhs: Block) -> Bool {
lhs.start == rhs.start &&
lhs.end == rhs.end &&
lhs.srcStableID == rhs.srcStableID &&
lhs.label == rhs.label &&
lhs.notes == rhs.notes &&
lhs.occurrence == rhs.occurrence
}
func hash(into hasher: inout Hasher) {
hasher.combine(start)
hasher.combine(end)
hasher.combine(srcStableID)
hasher.combine(label)
hasher.combine(notes)
hasher.combine(occurrence)
}
}
// De-dup blocks by occurrence (preferred) or by time range
func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] {
var seen = Set<String>()
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)
var curAlarms = sorted[0].alarmOffsets
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(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
cur = Block.span(start: b.start, end: b.end)
curAlarms = b.alarmOffsets
}
}
out.append(Block(start: cur.start, end: cur.end, srcStableID: nil, label: nil, notes: nil, occurrence: nil, alarmOffsets: curAlarms))
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
}