From 2e9989eab9ca2508b76a1660eb6b5d906095b601 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:32:54 +0200 Subject: [PATCH] Decide complication pushes from the next appointment's identity Co-Authored-By: Claude Opus 5 --- .../Transport/WatchPushPolicy.swift | 35 +++++++++ .../WatchPushPolicyTests.swift | 76 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 Sources/CalendarrCore/Transport/WatchPushPolicy.swift create mode 100644 Tests/CalendarrCoreTests/WatchPushPolicyTests.swift diff --git a/Sources/CalendarrCore/Transport/WatchPushPolicy.swift b/Sources/CalendarrCore/Transport/WatchPushPolicy.swift new file mode 100644 index 0000000..b95ed91 --- /dev/null +++ b/Sources/CalendarrCore/Transport/WatchPushPolicy.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Identifies the appointment a complication is currently showing. +/// +/// The start date is part of the identity on purpose: a moved event keeps its +/// id, and the complication is wrong until it is told. +public struct NextEventIdentity: Equatable, Sendable, Codable { + public let id: String + public let start: Date + + public init(id: String, start: Date) { + self.id = id + self.start = start + } +} + +public enum WatchPushPolicy { + /// The first event that has not ended yet — which is what every "next + /// appointment" complication renders, including one that is running now. + public static func nextEventIdentity(in snapshot: CalendarrSnapshot, + at now: Date) -> NextEventIdentity? { + snapshot.events + .filter { $0.end > now } + .min { $0.start < $1.start } + .map { NextEventIdentity(id: $0.id, start: $0.start) } + } + + /// Whether this change is worth one of the day's limited complication + /// pushes. Only a changed next appointment is — everything else reaches the + /// watch on the next application-context delivery. + public static func shouldPushComplicationUpdate(previous: NextEventIdentity?, + current: NextEventIdentity?) -> Bool { + previous != current + } +} diff --git a/Tests/CalendarrCoreTests/WatchPushPolicyTests.swift b/Tests/CalendarrCoreTests/WatchPushPolicyTests.swift new file mode 100644 index 0000000..a703009 --- /dev/null +++ b/Tests/CalendarrCoreTests/WatchPushPolicyTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import CalendarrCore + +struct WatchPushPolicyTests { + + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func event(_ id: String, startOffset: TimeInterval, + duration: TimeInterval = 3600) -> 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 next event is the first one that has not ended yet") + func picksFirstUnfinished() { + let snap = Self.snapshot([Self.event("past", startOffset: -7200), + Self.event("running", startOffset: -600), + Self.event("later", startOffset: 3600)]) + + #expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "running") + } + + @Test("An empty calendar has no next event") + func emptyHasNoIdentity() { + #expect(WatchPushPolicy.nextEventIdentity(in: Self.snapshot([]), at: Self.now) == nil) + } + + @Test("Events are considered in start order regardless of input order") + func sortsBeforePicking() { + let snap = Self.snapshot([Self.event("late", startOffset: 7200), + Self.event("soon", startOffset: 1800)]) + + #expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "soon") + } + + @Test("An unchanged next event does not spend a push") + func unchangedDoesNotPush() { + let identity = NextEventIdentity(id: "a", start: Self.now) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: identity, current: identity) == false) + } + + @Test("A different event, a moved event, or a first event all spend a push") + func changesPush() { + let a = NextEventIdentity(id: "a", start: Self.now) + let b = NextEventIdentity(id: "b", start: Self.now) + let moved = NextEventIdentity(id: "a", start: Self.now.addingTimeInterval(600)) + + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: b)) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: moved)) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: a)) + } + + @Test("Losing the last event spends a push, so the watch stops showing it") + func clearingPushes() { + let a = NextEventIdentity(id: "a", start: Self.now) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: nil)) + } + + @Test("Two empty calendars in a row do not push") + func stayingEmptyDoesNotPush() { + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: nil) == false) + } +}