Add the watch coverage policy and snapshot trimming
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cbec421367
commit
19454379e5
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// How much of the calendar travels to the watch.
|
||||||
|
///
|
||||||
|
/// Deliberately narrower than `SnapshotCoverage`: the payload crosses
|
||||||
|
/// WatchConnectivity on every publish, and a watch complication never needs six
|
||||||
|
/// weeks of history to answer "what is next".
|
||||||
|
public enum WatchCoverage {
|
||||||
|
/// Days before today. One is enough for "still running" all-day events.
|
||||||
|
public static let daysBehind = 1
|
||||||
|
/// Days after today.
|
||||||
|
public static let daysAhead = 14
|
||||||
|
/// Hard cap on events sent, applied after sorting by start date.
|
||||||
|
public static let maxEvents = 120
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CalendarrSnapshot {
|
||||||
|
/// A narrower copy of this snapshot, suitable for sending to the watch.
|
||||||
|
///
|
||||||
|
/// The coverage window shrinks together with the events, and never widens
|
||||||
|
/// past what the writer already claimed. If `WatchCoverage.maxEvents`
|
||||||
|
/// truncates the list, `coverageEnd` is pulled back to the first event we
|
||||||
|
/// had to drop: everything before that point is provably complete, and
|
||||||
|
/// beyond it the watch must not pretend to know anything.
|
||||||
|
public func trimmedForWatch(now: Date = Date(),
|
||||||
|
calendar: Calendar = Calendar(identifier: .gregorian)) -> CalendarrSnapshot {
|
||||||
|
let dayStart = calendar.startOfDay(for: now)
|
||||||
|
let windowStart = max(coverageStart,
|
||||||
|
calendar.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart) ?? dayStart)
|
||||||
|
let windowEnd = min(coverageEnd,
|
||||||
|
calendar.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart) ?? dayStart)
|
||||||
|
|
||||||
|
guard windowEnd > windowStart else {
|
||||||
|
return Self.rebuilt(from: self, start: windowStart, end: windowStart, events: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let inWindow = events
|
||||||
|
.filter { $0.start < windowEnd && $0.end > windowStart }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
guard inWindow.count > WatchCoverage.maxEvents else {
|
||||||
|
return Self.rebuilt(from: self, start: windowStart, end: windowEnd, events: inWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
let kept = Array(inWindow.prefix(WatchCoverage.maxEvents))
|
||||||
|
// The first dropped event marks the edge of what we can prove. Its start
|
||||||
|
// becomes the new end of coverage; the last kept event is dropped too
|
||||||
|
// when it starts at that same instant, which keeps the invariant
|
||||||
|
// "every event we carry starts before coverageEnd" exactly true.
|
||||||
|
let provenEnd = max(windowStart, min(windowEnd, inWindow[WatchCoverage.maxEvents].start))
|
||||||
|
return Self.rebuilt(from: self, start: windowStart, end: provenEnd,
|
||||||
|
events: kept.filter { $0.start < provenEnd && $0.end > windowStart })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rebuilt(from source: CalendarrSnapshot,
|
||||||
|
start: Date, end: Date,
|
||||||
|
events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
CalendarrSnapshot(writtenAt: source.writtenAt,
|
||||||
|
coverageStart: start,
|
||||||
|
coverageEnd: end,
|
||||||
|
isLoggedIn: source.isLoggedIn,
|
||||||
|
writerVersion: source.writerVersion,
|
||||||
|
events: events,
|
||||||
|
theme: source.theme,
|
||||||
|
language: source.language)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
/// The watch payload is a narrower snapshot, not a different one. These tests
|
||||||
|
/// pin down the part that is easy to get wrong: the coverage window has to
|
||||||
|
/// shrink with the events, or the watch renders an empty week it knows nothing
|
||||||
|
/// about.
|
||||||
|
struct WatchTrimmingTests {
|
||||||
|
|
||||||
|
private static let cal = Calendar(identifier: .gregorian)
|
||||||
|
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||||
|
|
||||||
|
private static func event(_ id: String, dayOffset: Int, hour: Int = 9) -> SnapshotEvent {
|
||||||
|
let dayStart = cal.startOfDay(for: now)
|
||||||
|
let day = cal.date(byAdding: .day, value: dayOffset, to: dayStart)!
|
||||||
|
let start = cal.date(byAdding: .hour, value: hour, to: day)!
|
||||||
|
return SnapshotEvent(id: id, title: "Event \(id)",
|
||||||
|
start: start, end: start.addingTimeInterval(3600),
|
||||||
|
isAllDay: false, colorHex: "#4285f4",
|
||||||
|
location: "", calendarKey: "local:1")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
let dayStart = cal.startOfDay(for: now)
|
||||||
|
return CalendarrSnapshot(
|
||||||
|
writtenAt: now,
|
||||||
|
coverageStart: cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart)!,
|
||||||
|
coverageEnd: cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)!,
|
||||||
|
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 window narrows to the watch policy, not just the events")
|
||||||
|
func narrowsWindow() {
|
||||||
|
let trimmed = Self.snapshot([]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
|
||||||
|
#expect(trimmed.coverageStart == Self.cal.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart))
|
||||||
|
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Events outside the watch window are dropped")
|
||||||
|
func dropsEventsOutsideWindow() {
|
||||||
|
let inside = Self.event("in", dayOffset: 3)
|
||||||
|
let outside = Self.event("out", dayOffset: 30)
|
||||||
|
let trimmed = Self.snapshot([inside, outside]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.events.map(\.id) == ["in"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An event still running at the window start is kept")
|
||||||
|
func keepsRunningEvent() {
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
let start = Self.cal.date(byAdding: .day, value: -5, to: dayStart)!
|
||||||
|
let running = SnapshotEvent(id: "running", title: "Urlaub",
|
||||||
|
start: start,
|
||||||
|
end: Self.cal.date(byAdding: .day, value: 5, to: dayStart)!,
|
||||||
|
isAllDay: true, colorHex: "#4285f4",
|
||||||
|
location: "", calendarKey: "local:1")
|
||||||
|
let trimmed = Self.snapshot([running]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.events.map(\.id) == ["running"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Metadata survives trimming unchanged")
|
||||||
|
func preservesMetadata() {
|
||||||
|
let original = Self.snapshot([Self.event("a", dayOffset: 1)])
|
||||||
|
let trimmed = original.trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.language == "de")
|
||||||
|
#expect(trimmed.isLoggedIn == true)
|
||||||
|
#expect(trimmed.writerVersion == "1.0")
|
||||||
|
#expect(trimmed.theme.accent == "#6")
|
||||||
|
#expect(trimmed.writtenAt == original.writtenAt)
|
||||||
|
#expect(trimmed.schemaVersion == CalendarrSnapshot.currentSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("When the event cap truncates, coverageEnd pulls back to what we can prove")
|
||||||
|
func capPullsBackCoverage() {
|
||||||
|
var many: [SnapshotEvent] = []
|
||||||
|
for day in 0..<14 {
|
||||||
|
for hour in 8..<18 { many.append(Self.event("d\(day)h\(hour)", dayOffset: day, hour: hour)) }
|
||||||
|
}
|
||||||
|
#expect(many.count > WatchCoverage.maxEvents)
|
||||||
|
|
||||||
|
let trimmed = Self.snapshot(many).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.events.count <= WatchCoverage.maxEvents)
|
||||||
|
#expect(trimmed.events.allSatisfy { $0.start < trimmed.coverageEnd })
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
let fullEnd = Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)!
|
||||||
|
#expect(trimmed.coverageEnd < fullEnd)
|
||||||
|
#expect(trimmed.coverageEnd > trimmed.coverageStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Trimming never widens a window the writer already narrowed")
|
||||||
|
func neverWidens() {
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
let narrow = CalendarrSnapshot(
|
||||||
|
writtenAt: Self.now,
|
||||||
|
coverageStart: dayStart,
|
||||||
|
coverageEnd: Self.cal.date(byAdding: .day, value: 2, to: dayStart)!,
|
||||||
|
isLoggedIn: true, writerVersion: "1.0", events: [],
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "de")
|
||||||
|
|
||||||
|
let trimmed = narrow.trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.coverageStart == dayStart)
|
||||||
|
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: 2, to: dayStart))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user