From 4e220de3a7590f53265212d830388e6353c822a0 Mon Sep 17 00:00:00 2001 From: Josh Priddle Date: Tue, 17 Feb 2026 14:47:40 -0500 Subject: [PATCH] Show multi-day events in each day they occur on --- .../ICalGuyKit/Services/EventGrouper.swift | 33 ++++- Tests/ICalGuyKitTests/EventGrouperTests.swift | 135 +++++++++++++++++- 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/Sources/ICalGuyKit/Services/EventGrouper.swift b/Sources/ICalGuyKit/Services/EventGrouper.swift index b08787f..1a6a391 100644 --- a/Sources/ICalGuyKit/Services/EventGrouper.swift +++ b/Sources/ICalGuyKit/Services/EventGrouper.swift @@ -18,9 +18,36 @@ public struct EventGrouper: Sendable { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate] - let grouped = Dictionary(grouping: events) { event -> String in - let day = calendar.startOfDay(for: event.startDate) - return formatter.string(from: day) + var grouped: [String: [CalendarEvent]] = [:] + + 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 + } + } + + var day = eventStart + while day <= effectiveEnd { + let key = formatter.string(from: day) + grouped[key, default: []].append(event) + guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } } if showEmptyDates, let from, let to { diff --git a/Tests/ICalGuyKitTests/EventGrouperTests.swift b/Tests/ICalGuyKitTests/EventGrouperTests.swift index c93a4cd..ed21ba5 100644 --- a/Tests/ICalGuyKitTests/EventGrouperTests.swift +++ b/Tests/ICalGuyKitTests/EventGrouperTests.swift @@ -27,6 +27,7 @@ final class EventGrouperTests: XCTestCase { title: String = "Meeting", startDate: Date, endDate: Date, + isAllDay: Bool = false, calendarId: String = "cal-1", calendarTitle: String = "Work", calendarColor: String = "#1BADF8" @@ -36,7 +37,7 @@ final class EventGrouperTests: XCTestCase { title: title, startDate: startDate, endDate: endDate, - isAllDay: false, + isAllDay: isAllDay, location: nil, notes: nil, url: nil, @@ -146,6 +147,138 @@ final class EventGrouperTests: XCTestCase { XCTAssertTrue(groups.allSatisfy { $0.events.isEmpty }) } + // MARK: - Multi-day Event Spanning + + func testMultiDayAllDayEventSpansAllDates() { + // 3-day all-day event: Mar 15-17 (EventKit end = Mar 18 00:00) + let events = [ + makeEvent( + id: "1", title: "Conference", + startDate: date(2024, 3, 15), endDate: date(2024, 3, 18), + isAllDay: true), + ] + + let groups = grouper.groupByDate(events) + + XCTAssertEqual(groups.count, 3) + XCTAssertEqual(groups[0].date, "2024-03-15") + XCTAssertEqual(groups[0].events.count, 1) + XCTAssertEqual(groups[0].events[0].title, "Conference") + XCTAssertEqual(groups[1].date, "2024-03-16") + XCTAssertEqual(groups[1].events.count, 1) + XCTAssertEqual(groups[1].events[0].title, "Conference") + XCTAssertEqual(groups[2].date, "2024-03-17") + XCTAssertEqual(groups[2].events.count, 1) + XCTAssertEqual(groups[2].events[0].title, "Conference") + } + + func testSingleDayAllDayEventStaysInOneGroup() { + // 1-day all-day event: Mar 15 (EventKit end = Mar 16 00:00) + let events = [ + makeEvent( + id: "1", title: "Holiday", + startDate: date(2024, 3, 15), endDate: date(2024, 3, 16), + isAllDay: true), + ] + + let groups = grouper.groupByDate(events) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups[0].date, "2024-03-15") + XCTAssertEqual(groups[0].events.count, 1) + } + + func testTimedOvernightEventSpansTwoDays() { + // Timed event from 10pm Mar 15 to 2am Mar 16 + let events = [ + makeEvent( + id: "1", title: "Late Night", + startDate: date(2024, 3, 15, 22), endDate: date(2024, 3, 16, 2)), + ] + + let groups = grouper.groupByDate(events) + + XCTAssertEqual(groups.count, 2) + XCTAssertEqual(groups[0].date, "2024-03-15") + XCTAssertEqual(groups[0].events.count, 1) + XCTAssertEqual(groups[1].date, "2024-03-16") + XCTAssertEqual(groups[1].events.count, 1) + } + + func testTimedEventEndingAtMidnightStaysInOneGroup() { + // Timed event from 10pm to midnight — should not spill into next day + let events = [ + makeEvent( + id: "1", title: "Evening Event", + startDate: date(2024, 3, 15, 22), endDate: date(2024, 3, 16)), + ] + + let groups = grouper.groupByDate(events) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups[0].date, "2024-03-15") + XCTAssertEqual(groups[0].events.count, 1) + } + + func testMultiDayEventWithShowEmptyDates() { + // 3-day all-day event spanning the full range, plus an empty day + let events = [ + makeEvent( + id: "1", title: "Conference", + startDate: date(2024, 3, 15), endDate: date(2024, 3, 18), + isAllDay: true), + ] + + let groups = grouper.groupByDate( + events, + from: date(2024, 3, 14), + to: date(2024, 3, 18), + showEmptyDates: true + ) + + XCTAssertEqual(groups.count, 5) + XCTAssertEqual(groups[0].date, "2024-03-14") + XCTAssertEqual(groups[0].events.count, 0) + XCTAssertEqual(groups[1].date, "2024-03-15") + XCTAssertEqual(groups[1].events.count, 1) + XCTAssertEqual(groups[2].date, "2024-03-16") + XCTAssertEqual(groups[2].events.count, 1) + XCTAssertEqual(groups[3].date, "2024-03-17") + XCTAssertEqual(groups[3].events.count, 1) + XCTAssertEqual(groups[4].date, "2024-03-18") + XCTAssertEqual(groups[4].events.count, 0) + } + + func testMultiDaySpanningWithOtherSingleDayEvents() { + // A spanning event plus regular single-day events + let events = [ + makeEvent( + id: "1", title: "Conference", + startDate: date(2024, 3, 15), endDate: date(2024, 3, 18), + isAllDay: true), + makeEvent( + id: "2", title: "Lunch", + startDate: date(2024, 3, 16, 12), endDate: date(2024, 3, 16, 13)), + ] + + let groups = grouper.groupByDate(events) + + XCTAssertEqual(groups.count, 3) + + let mar15 = groups.first { $0.date == "2024-03-15" }! + XCTAssertEqual(mar15.events.count, 1) + XCTAssertEqual(mar15.events[0].title, "Conference") + + let mar16 = groups.first { $0.date == "2024-03-16" }! + XCTAssertEqual(mar16.events.count, 2) + XCTAssertTrue(mar16.events.contains { $0.title == "Conference" }) + XCTAssertTrue(mar16.events.contains { $0.title == "Lunch" }) + + let mar17 = groups.first { $0.date == "2024-03-17" }! + XCTAssertEqual(mar17.events.count, 1) + XCTAssertEqual(mar17.events[0].title, "Conference") + } + // MARK: - Calendar Grouping func testGroupByCalendar() {