diff --git a/Sources/ICalGuyKit/Services/DateGrouper.swift b/Sources/ICalGuyKit/Services/DateGrouper.swift index c32d09e..ffcd9bc 100644 --- a/Sources/ICalGuyKit/Services/DateGrouper.swift +++ b/Sources/ICalGuyKit/Services/DateGrouper.swift @@ -20,24 +20,12 @@ public struct DateGrouper: Sendable { for event in events { let eventStart = calendar.startOfDay(for: event.startDate) - let eventEnd = calendar.startOfDay(for: event.endDate) - let effectiveEnd: Date - if event.isAllDay { - // All-day events: EventKit sets endDate to midnight of the *next* day. - // Subtract 1 day so a 1-day event stays in 1 bucket, a 3-day event spans 3 days. - effectiveEnd = calendar.date(byAdding: .day, value: -1, to: eventEnd) ?? eventEnd - } else { - // Timed events ending exactly at midnight: don't spill into the next day - let endComponents = calendar.dateComponents([.hour, .minute, .second], from: event.endDate) - if endComponents.hour == 0 && endComponents.minute == 0 && endComponents.second == 0 - && eventEnd > eventStart - { - effectiveEnd = calendar.date(byAdding: .day, value: -1, to: eventEnd) ?? eventEnd - } else { - effectiveEnd = eventEnd - } - } + // Bucket by the last instant the event occupies. EventKit ends all-day events at + // 23:59:59 of the last day (not midnight of the next), and timed events ending exactly + // at midnight shouldn't spill into the next day. Zero-duration events stay on their start day. + let lastInstant = max(event.startDate, event.endDate.addingTimeInterval(-1)) + let effectiveEnd = calendar.startOfDay(for: lastInstant) var day = eventStart // Clip to query range boundaries diff --git a/Tests/ICalGuyKitTests/DateGrouperTests.swift b/Tests/ICalGuyKitTests/DateGrouperTests.swift index cf24057..c0705ab 100644 --- a/Tests/ICalGuyKitTests/DateGrouperTests.swift +++ b/Tests/ICalGuyKitTests/DateGrouperTests.swift @@ -170,6 +170,51 @@ final class DateGrouperTests: XCTestCase { XCTAssertEqual(groups[0].events.count, 1) } + // Real EventKit ends all-day events at 23:59:59 of the last day, not midnight of the next. + + func testSingleDayAllDayEventWithEventKitEndDate() { + let events = [ + makeEvent( + id: "1", title: "Holiday", + startDate: date(2024, 3, 15), endDate: date(2024, 3, 16).addingTimeInterval(-1), + isAllDay: true) + ] + + let groups = grouper.groupByDate(events, from: date(2024, 3, 1), to: date(2024, 3, 31)) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups[0].date, "2024-03-15") + XCTAssertEqual(groups[0].events.count, 1) + } + + func testMultiDayAllDayEventWithEventKitEndDateIncludesLastDay() { + // Mar 15-17 inclusive + let events = [ + makeEvent( + id: "1", title: "Conference", + startDate: date(2024, 3, 15), endDate: date(2024, 3, 18).addingTimeInterval(-1), + isAllDay: true) + ] + + let groups = grouper.groupByDate(events) + + XCTAssertEqual(groups.map(\.date), ["2024-03-15", "2024-03-16", "2024-03-17"]) + } + + func testAllDayEventWithEventKitEndDateClippedToRange() { + // Mar 14-17 inclusive, queried Mar 15-16 + let events = [ + makeEvent( + id: "1", title: "Conference", + startDate: date(2024, 3, 14), endDate: date(2024, 3, 18).addingTimeInterval(-1), + isAllDay: true) + ] + + let groups = grouper.groupByDate(events, from: date(2024, 3, 15), to: date(2024, 3, 16)) + + XCTAssertEqual(groups.map(\.date), ["2024-03-15", "2024-03-16"]) + } + func testTimedOvernightEventSpansTwoDays() { // Timed event from 10pm Mar 15 to 2am Mar 16 let events = [