Compute complication timeline entries from event boundaries

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-11 16:32:54 +02:00
co-authored by Claude Opus 5
parent 2e9989eab9
commit d70cdca4e2
2 changed files with 136 additions and 0 deletions
@@ -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<Int> = []
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))
}
}
@@ -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())
}
}