Compute complication timeline entries from event boundaries
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2e9989eab9
commit
d70cdca4e2
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user