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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to KDCalendar are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
[Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- An event with a far-off end date, such as an open-ended system event, was
bucketed day by day over its whole span and could block the main thread for
seconds. Bucketing is clamped to the displayed months.
- The data source was asked for its range on every cell (about 150 calls per
reload); it is asked once per reload.
- The today marker did not move at midnight or after a clock or time zone
change; the view reloads on `NSCalendarDayChanged` and
`significantTimeChangeNotification`.
- Weekends ignored `style.locale` when `style.calendar` had no locale, so a
Saudi calendar marked Sunday instead of Friday. `CalendarView.calendar` now
carries the style's locale in that case.

## [2.0.0] - 2026-09-08

The modernisation release. Swift 6, Swift Package Manager, iOS 17 and up. The
Expand Down
36 changes: 24 additions & 12 deletions Sources/KDCalendar/CalendarView+DataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,37 @@ struct MonthGrid {

extension CalendarView {

/// The month grid for the data source's current range, rebuilt when the range moves to
/// other days. The current month alone without a data source; zero months when the range
/// is invalid.
var currentMonths: MonthGrid? {
/// Asks the data source for its range and rebuilds the month grid when the range moved to
/// other days or the calendar changed. The current month alone without a data source; zero
/// months when the range is invalid. Called once per reload, not once per cell.
@discardableResult
func refreshMonths() -> MonthGrid? {
let start = dataSource?.startDate() ?? Date()
let end = dataSource?.endDate() ?? start
let calendar = self.calendar
if let months = months,
calendar.isDate(months.startDay, inSameDayAs: start),
calendar.isDate(months.endDay, inSameDayAs: end),
months.calendar == calendar
{
todayIndexPath = months.indexPath(for: Date())
return months
}
months = MonthGrid(start: start, end: end, calendar: calendar, firstWeekday: style.effectiveFirstWeekday)
if months == nil {
CalendarView.logger.error(
"The data source's start date (\(start)) is after its end date (\(end)); showing no months.")
}
todayIndexPath = months?.indexPath(for: Date())
rebuildEventIndex()
return months
}

/// The month grid, built on first use. Reloads refresh it from the data source.
var currentMonths: MonthGrid? {
months ?? refreshMonths()
}

func invalidateMonths() {
months = nil
}
Expand All @@ -150,16 +159,19 @@ extension CalendarView {
currentMonths?.endDay ?? calendar.startOfDay(for: Date())
}

var todayIndexPath: IndexPath? {
currentMonths?.indexPath(for: Date())
}

/// Buckets every event into the days it covers, clamped to the displayed months so an
/// open-ended event costs one loop over the grid, not over the centuries.
func rebuildEventIndex() {
eventsByIndexPath.removeAll()
guard let months = months else { return }
guard let months = months, let first = months.months.first, let lastMonth = months.months.last,
let gridEnd = calendar.date(byAdding: .day, value: lastMonth.daysTotal, to: lastMonth.firstDate)
else { return }
let gridStart = first.firstDate
for event in events {
var day = calendar.startOfDay(for: event.startDate)
let last = max(event.startDate, event.endDate)
let eventEnd = max(event.startDate, event.endDate)
guard event.startDate < gridEnd, eventEnd >= gridStart else { continue }
var day = calendar.startOfDay(for: max(event.startDate, gridStart))
let last = min(eventEnd, gridEnd)
repeat {
if let indexPath = months.indexPath(for: day) {
eventsByIndexPath[indexPath, default: []].append(event)
Expand Down Expand Up @@ -195,7 +207,7 @@ extension CalendarView {
extension CalendarView: UICollectionViewDataSource {

public func numberOfSections(in collectionView: UICollectionView) -> Int {
return currentMonths?.numberOfSections ?? 0
return refreshMonths()?.numberOfSections ?? 0
}

public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
Expand Down
28 changes: 25 additions & 3 deletions Sources/KDCalendar/CalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,14 @@ public class CalendarView: UIView {
}
}

/// The calendar used for every date computation. Shorthand for `style.calendar`.
/// The calendar used for every date computation: `style.calendar`, given `style.locale`
/// when it has no locale of its own so that weekends and week numbering follow the locale.
public var calendar: Calendar {
return style.calendar
var calendar = style.calendar
if calendar.locale == nil || calendar.locale?.identifier.isEmpty == true {
calendar.locale = style.locale
}
return calendar
}

/// The days the data source currently spans, from `startDate()` to `endDate()`, at the
Expand All @@ -138,6 +143,9 @@ public class CalendarView: UIView {

/// The month grid derived from the data source. Rebuilt whenever the range changes.
var months: MonthGrid?
/// The cell showing today, refreshed with the grid and when the day changes.
var todayIndexPath: IndexPath?
nonisolated(unsafe) private var observers: [NSObjectProtocol] = []
var eventsByIndexPath = [IndexPath: [CalendarEvent]]()

/// Events to show as dots. An event marks every day it covers.
Expand Down Expand Up @@ -315,6 +323,19 @@ public class CalendarView: UIView {
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(CalendarView.handleLongPress))
self.collectionView.addGestureRecognizer(longPress)

// Keep the today marker honest across midnight and clock or time zone changes.
for name in [Notification.Name.NSCalendarDayChanged, UIApplication.significantTimeChangeNotification] {
observers.append(
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.reloadData() }
})
}
}

deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}

@objc func handleLongPress(gesture: UILongPressGestureRecognizer) {
Expand Down Expand Up @@ -444,9 +465,10 @@ public class CalendarView: UIView {
// MARK: - Public methods
extension CalendarView {

/// Reloads every day cell, keeping the selection.
/// Reloads every day cell, keeping the selection. Asks the data source for its range again.
public func reloadData() {
guard let collectionView = self.collectionView else { return }
refreshMonths()
collectionView.reloadData()
for indexPath in selectedIndexPaths {
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
Expand Down
205 changes: 205 additions & 0 deletions Tests/KDCalendarTests/EngineTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import Testing
import UIKit

@testable import KDCalendar

/// Edge cases of the date engine: DST, calendars, locales, long events and data source traffic.
@Suite(.serialized)
@MainActor
struct EngineTests {

final class CountingDataSource: CalendarViewDataSource {
var start: Date
var end: Date
var calls = 0
init(start: Date, end: Date) {
self.start = start
self.end = end
}
func startDate() -> Date {
calls += 1
return start
}
func endDate() -> Date {
calls += 1
return end
}
}

static let window: UIWindow = {
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 350, height: 500))
window.isHidden = false
return window
}()

final class Retained {
var objects: [AnyObject] = []
}
let retained = Retained()

init() {
Self.window.subviews.forEach { $0.removeFromSuperview() }
}

private func calendar(_ zone: String, identifier: Calendar.Identifier = .gregorian, locale: String? = nil)
-> Calendar
{
var calendar = Calendar(identifier: identifier)
calendar.timeZone = TimeZone(identifier: zone)!
if let locale { calendar.locale = Locale(identifier: locale) }
return calendar
}

private func date(_ calendar: Calendar, _ year: Int, _ month: Int, _ day: Int, hour: Int = 0) -> Date {
calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))!
}

private func makeCalendar(start: Date, end: Date, calendar: Calendar, locale: String = "en_US") -> CalendarView {
var style = CalendarView.Style()
style.locale = Locale(identifier: locale)
style.calendar = calendar
let view = CalendarView(frame: CGRect(x: 0, y: 0, width: 350, height: 420))
view.style = style
let dataSource = CountingDataSource(start: start, end: end)
retained.objects.append(dataSource)
view.dataSource = dataSource
Self.window.addSubview(view)
view.layoutIfNeeded()
return view
}

private func cell(_ view: CalendarView, _ indexPath: IndexPath) -> CalendarDayCell? {
view.collectionView.cellForItem(at: indexPath) as? CalendarDayCell
}

// MARK: Daylight saving time

@Test func everyDayOfADSTYearRoundTrips() {
let athens = calendar("Europe/Athens")
let view = makeCalendar(start: date(athens, 2024, 1, 1), end: date(athens, 2024, 12, 31), calendar: athens)
var day = date(athens, 2024, 1, 1)
let last = date(athens, 2024, 12, 31)
var count = 0
while day <= last {
let indexPath = view.indexPathForDate(day)
#expect(indexPath != nil, "\(day)")
#expect(view.dateFromIndexPath(indexPath!) == day, "\(day)")
#expect(view.dateFromIndexPath(indexPath!) == athens.startOfDay(for: day), "\(day)")
// Any time of that day, including the DST switch hours, lands on the same cell.
#expect(view.indexPathForDate(day.addingTimeInterval(3 * 3600)) == indexPath, "\(day) + 3h")
let nextDay = athens.date(byAdding: .day, value: 1, to: day)!
#expect(view.indexPathForDate(nextDay.addingTimeInterval(-60)) == indexPath, "\(day), last minute")
day = nextDay
count += 1
}
#expect(count == 366)
#expect(view.numberOfSections(in: view.collectionView) == 12)
}

@Test func aDaySwitchingToDSTAtMidnightStillMatchesStartOfDay() {
// Chile switches to summer time on 8 September 2024 at 00:00, so that day starts at 01:00.
let santiago = calendar("America/Santiago")
let view = makeCalendar(start: date(santiago, 2024, 9, 1), end: date(santiago, 2024, 9, 30), calendar: santiago)
let switchDay = santiago.startOfDay(for: date(santiago, 2024, 9, 8, hour: 12))
#expect(santiago.component(.hour, from: switchDay) == 1, "the day really starts at 01:00")
let indexPath = view.indexPathForDate(switchDay)!
#expect(view.dateFromIndexPath(indexPath) == switchDay)
#expect(santiago.component(.day, from: view.dateFromIndexPath(indexPath)!) == 8)
#expect(view.indexPathForDate(date(santiago, 2024, 9, 9)) == IndexPath(item: indexPath.item + 1, section: 0))
view.selectDate(date(santiago, 2024, 9, 8, hour: 15))
#expect(view.selectedDates == [switchDay])
view.deselectDate(switchDay)
#expect(view.selectedDates == [])
}

// MARK: Events

@Test func anEventWithAFarEndDateIsClampedToTheGrid() {
let utc = calendar("UTC")
let view = makeCalendar(start: date(utc, 2024, 1, 1), end: date(utc, 2024, 1, 31), calendar: utc)
let started = Date()
view.events = [
CalendarEvent(title: "forever", startDate: date(utc, 2024, 1, 30), endDate: date(utc, 4000, 1, 1)),
CalendarEvent(title: "ancient", startDate: date(utc, 1, 1, 1), endDate: date(utc, 2024, 1, 2)),
CalendarEvent(title: "elsewhere", startDate: date(utc, 2030, 1, 1), endDate: date(utc, 2031, 1, 1)),
]
#expect(Date().timeIntervalSince(started) < 1.0, "no per-day loop over the centuries")
#expect(view.eventsByIndexPath[IndexPath(item: 29, section: 0)]?.map(\.title) == ["forever"])
#expect(view.eventsByIndexPath[IndexPath(item: 30, section: 0)]?.map(\.title) == ["forever"])
#expect(view.eventsByIndexPath[IndexPath(item: 0, section: 0)]?.map(\.title) == ["ancient"])
#expect(view.eventsByIndexPath[IndexPath(item: 1, section: 0)] == nil)
#expect(view.eventsByIndexPath.values.flatMap { $0 }.filter { $0.title == "elsewhere" }.isEmpty)
}

// MARK: Data source traffic

@Test func theDataSourceIsAskedOncePerReloadNotOncePerCell() {
let utc = calendar("UTC")
let view = makeCalendar(start: date(utc, 2024, 1, 1), end: date(utc, 2024, 3, 31), calendar: utc)
let source = view.dataSource as! CountingDataSource
source.calls = 0
view.reloadData()
view.layoutIfNeeded()
#expect(source.calls <= 4, "was \(source.calls)")
source.calls = 0
view.setDisplayDate(date(utc, 2024, 2, 1))
view.layoutIfNeeded()
#expect(source.calls <= 6, "was \(source.calls)")
}

// MARK: Locales and calendars

@Test func weekendsFollowTheStyleLocaleWhenTheCalendarHasNone() {
// Saudi Arabia's weekend is Friday and Saturday.
let utc = calendar("UTC")
let view = makeCalendar(
start: date(utc, 2024, 1, 1), end: date(utc, 2024, 1, 31), calendar: utc, locale: "ar_SA")
#expect(view.calendar.locale?.identifier == "ar_SA")
#expect(view.calendar.isDateInWeekend(date(utc, 2024, 1, 5)) == true, "Friday")
#expect(view.calendar.isDateInWeekend(date(utc, 2024, 1, 7)) == false, "Sunday")
// January 2024 starts on a Monday: Friday the 5th is item 4, Sunday the 7th item 6.
#expect(cell(view, IndexPath(item: 4, section: 0))?.isWeekend == true)
#expect(cell(view, IndexPath(item: 6, section: 0))?.isWeekend == false)
// A calendar that already has a locale keeps it.
let explicit = makeCalendar(
start: date(utc, 2024, 1, 1), end: date(utc, 2024, 1, 31), calendar: calendar("UTC", locale: "en_US"),
locale: "ar_SA")
#expect(explicit.calendar.locale?.identifier == "en_US")
#expect(cell(explicit, IndexPath(item: 4, section: 0))?.isWeekend == false)
}

@Test func hebrewLeapYearsHaveThirteenMonths() {
// 5784 (2023-24) is a leap year with Adar I and Adar II.
let hebrew = calendar("UTC", identifier: .hebrew)
let gregorian = calendar("UTC")
let view = makeCalendar(
start: date(gregorian, 2023, 9, 16), end: date(gregorian, 2024, 10, 2), calendar: hebrew)
#expect(view.numberOfSections(in: view.collectionView) == 13)
var day = date(gregorian, 2023, 9, 16)
let last = date(gregorian, 2024, 10, 2)
while day <= last {
#expect(view.dateFromIndexPath(view.indexPathForDate(day)!) == day, "\(day)")
day = gregorian.date(byAdding: .day, value: 1, to: day)!
}
}

// MARK: Today

@Test func theTodayMarkerMovesWhenTheDayChanges() {
let local = Calendar.current
let now = Date()
let view = makeCalendar(
start: local.date(byAdding: .day, value: -10, to: now)!,
end: local.date(byAdding: .day, value: 10, to: now)!,
calendar: local)
view.setDisplayDate(now)
view.layoutIfNeeded()
let today = view.indexPathForDate(now)!
#expect(cell(view, today)?.isToday == true)
// Pretend the marker went stale, as it would across midnight, then announce the day change.
cell(view, today)?.isToday = false
NotificationCenter.default.post(name: .NSCalendarDayChanged, object: nil)
view.layoutIfNeeded()
#expect(cell(view, today)?.isToday == true)
}
}
Loading