diff --git a/Sources/CalendarrCore/Complications/ComplicationTimeline.swift b/Sources/CalendarrCore/Complications/ComplicationTimeline.swift new file mode 100644 index 0000000..06ae07f --- /dev/null +++ b/Sources/CalendarrCore/Complications/ComplicationTimeline.swift @@ -0,0 +1,47 @@ +import Foundation + +/// When a complication needs to be redrawn. +/// +/// A watch-face complication has a small refresh budget, so the timeline is +/// pre-computed from data the watch already holds instead of being refreshed on +/// a schedule: an entry on every event boundary means the complication changes +/// in the exact minute the next appointment does, without a network call. +public enum ComplicationTimeline { + /// Dates at which a complication should re-render. + /// + /// Always begins with `now` so the first entry renders immediately. Event + /// starts and ends within `horizon` become entries; hourly marks fill the + /// rest so a quiet calendar still advances its date and countdown. The + /// result is sorted, deduplicated to the second, and capped at + /// `maxEntries`, keeping the earliest. + public static func entryDates(from now: Date, + in snapshot: CalendarrSnapshot, + horizon: TimeInterval = 24 * 3600, + maxEntries: Int = 100) -> [Date] { + let end = now.addingTimeInterval(horizon) + var seen: Set = [] + var dates: [Date] = [] + + func add(_ date: Date) { + guard date >= now, date <= end else { return } + // Deduplicate to whole seconds: two events that meet exactly would + // otherwise produce two entries a float apart. + let key = Int(date.timeIntervalSince1970.rounded()) + guard seen.insert(key).inserted else { return } + dates.append(date) + } + + add(now) + for event in snapshot.events { + add(event.start) + add(event.end) + } + var hourly = now.addingTimeInterval(3600) + while hourly <= end { + add(hourly) + hourly = hourly.addingTimeInterval(3600) + } + + return Array(dates.sorted().prefix(maxEntries)) + } +} diff --git a/Tests/CalendarrCoreTests/ComplicationTimelineTests.swift b/Tests/CalendarrCoreTests/ComplicationTimelineTests.swift new file mode 100644 index 0000000..bfe1594 --- /dev/null +++ b/Tests/CalendarrCoreTests/ComplicationTimelineTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import CalendarrCore + +struct ComplicationTimelineTests { + + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func event(_ id: String, startOffset: TimeInterval, + duration: TimeInterval) -> SnapshotEvent { + SnapshotEvent(id: id, title: id, + start: now.addingTimeInterval(startOffset), + end: now.addingTimeInterval(startOffset + duration), + isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1") + } + + private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: now, + coverageStart: now.addingTimeInterval(-86400), + coverageEnd: now.addingTimeInterval(14 * 86400), + isLoggedIn: true, writerVersion: "1.0", events: events, + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + } + + @Test("The first entry is always now, so the complication renders immediately") + func startsAtNow() { + let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([])) + #expect(dates.first == Self.now) + } + + @Test("Entries are sorted and free of duplicates") + func sortedAndDeduplicated() { + let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800), + Self.event("b", startOffset: 5400, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap) + + #expect(dates == dates.sorted()) + #expect(Set(dates).count == dates.count) + } + + @Test("Event starts and ends inside the horizon become entries") + func includesEventBoundaries() { + let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap) + + #expect(dates.contains(Self.now.addingTimeInterval(3600))) + #expect(dates.contains(Self.now.addingTimeInterval(5400))) + } + + @Test("Boundaries outside the horizon are ignored") + func ignoresBoundariesBeyondHorizon() { + let snap = Self.snapshot([Self.event("far", startOffset: 40 * 3600, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap, horizon: 24 * 3600) + + #expect(dates.contains(Self.now.addingTimeInterval(40 * 3600)) == false) + #expect(dates.allSatisfy { $0 <= Self.now.addingTimeInterval(24 * 3600) }) + } + + @Test("Boundaries already in the past are ignored") + func ignoresPastBoundaries() { + let snap = Self.snapshot([Self.event("past", startOffset: -7200, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap) + + #expect(dates.allSatisfy { $0 >= Self.now }) + } + + @Test("An empty calendar still gets hourly entries across the horizon") + func fillsHourlyWhenEmpty() { + let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]), + horizon: 24 * 3600) + #expect(dates.count >= 24) + } + + @Test("The entry count is capped, keeping the earliest entries") + func capsEntries() { + var events: [SnapshotEvent] = [] + for i in 0..<300 { + events.append(Self.event("e\(i)", startOffset: Double(i) * 60, duration: 30)) + } + let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot(events), + maxEntries: 50) + + #expect(dates.count == 50) + #expect(dates.first == Self.now) + #expect(dates == dates.sorted()) + } +}