Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions Sources/ICalGuyKit/Services/EventGrouper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
135 changes: 134 additions & 1 deletion Tests/ICalGuyKitTests/EventGrouperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -36,7 +37,7 @@ final class EventGrouperTests: XCTestCase {
title: title,
startDate: startDate,
endDate: endDate,
isAllDay: false,
isAllDay: isAllDay,
location: nil,
notes: nil,
url: nil,
Expand Down Expand Up @@ -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() {
Expand Down