CalendarDate

Swift code for handling Date objects at calendar date-granularity

Background

If you want to represent a date in Swift, the Date type is available, but this type is kind of weirdly named. It doesn’t just represent a date on a calendar, but as per the documentation, a “specific point in time”. And it is very specific. Some of the standard interfaces for manipulating Date values use TimeInterval as the differential, which is described as having sub-millisecond precision. If you only want to represent a specific date on a calendar, this can make the Date type a little unwieldy.

CalendarDate

This is why I created a CalendarDate type in my DailyBudget project (which is heavily concerned with what day things happened on, but not so much what time). I’ve moved the class definition and extensions to a GitHub Gist; you can find it here. I’m currently trying to smash out a new app for a simple idea that also involves dates, so the hope is that this way of dealing with calendar dates is fairly reusable.

You can also read the code below.

Disclaimer

I am fairly ignorant of non-Gregorian calendars, and don’t know whether this works correctly in all locales.

Code

import Foundation

/// Provides ergonomics for treating Swift Dates at day-specific granularity
struct CalendarDate {
  let date: Date
  private var calendar: Calendar { .current }
}

// MARK: Creating instances -

extension CalendarDate {
  static var today: CalendarDate {
    .init(date: Calendar.current.startOfDay(for: .now))
  }
  
  init(year: Int, month: Int, day: Int) {
    self.init(
      date: Calendar.current.date(
        from: DateComponents(year: year, month: month, day: day))!)
  }
  
  func adding(days: Int) -> CalendarDate {
    CalendarDate(
      date: calendar.date(
        byAdding: DateComponents(day: days), to: date)!)
  }
}

// MARK: Days since -

extension CalendarDate {
  static func -(lhs: CalendarDate, rhs: CalendarDate) -> Int {
    Calendar.current.numberOfDaysBetween(rhs.date, and: lhs.date)
  }
}

private extension Calendar {
  func numberOfDaysBetween(_ from: Date, and to: Date) -> Int {
      let fromDate = startOfDay(for: from)
      let toDate = startOfDay(for: to)
      let numberOfDays = dateComponents([.day], from: fromDate, to: toDate)
      
      return numberOfDays.day!
  }
}

// MARK: Nice formating -

extension CalendarDate {
  func toStandardFormatting() -> String {
    if Calendar.current.isDate(self.date, equalTo: .now, toGranularity: .year) {
      // Omit year if in the same year
      return self.date.formatted(.dateTime.day().month(.wide))
    } else {
      return self.date.formatted(.dateTime.day().month(.wide).year())
    }
  }
}

// MARK: Comparisons -

extension CalendarDate: Equatable {
  static func ==(lhs: CalendarDate, rhs: CalendarDate) -> Bool {
    Calendar.current.isDate(lhs.date, equalTo: rhs.date, toGranularity: .day)
  }
}

extension CalendarDate: Comparable {
  static func < (lhs: CalendarDate, rhs: CalendarDate) -> Bool {
    lhs != rhs && lhs.date < rhs.date
  }
}

// MARK: Hashable -

extension CalendarDate: Hashable {
  /// Custom implementation with day granularity
  func hash(into hasher: inout Hasher) {
    calendar.dateComponents([.year, .month, .day], from: date)
      .hash(into: &hasher)
  }
}