Get first Monday in Calendar month

i0S Swift Issue

Question or problem in the Swift programming language:

I am trapped in a Calendar/TimeZone/DateComponents/Date hell and my mind is spinning.

I am creating a diary app and I want it to work worldwide (with a iso8601 calendar).

I am trying to create a calendar view on iOS similar to the macOS calendar so that my user can navigate their diary entries (notice the 7×6 grid for each month):

To get the 1st of the month I can do something like the following:

func firstOfMonth(month: Int, year: Int) -> Date {

  let calendar = Calendar(identifier: .iso8601)

  var firstOfMonthComponents = DateComponents()
  // firstOfMonthComponents.timeZone = TimeZone(identifier: "UTC") // <- fixes daylight savings time
  firstOfMonthComponents.calendar = calendar
  firstOfMonthComponents.year = year
  firstOfMonthComponents.month = month
  firstOfMonthComponents.day = 01

  return firstOfMonthComponents.date!

}

(1...12).forEach {

  print(firstOfMonth(month: $0, year: 2018))

  /*

   Gives:

   2018-01-01 00:00:00 +0000
   2018-02-01 00:00:00 +0000
   2018-03-01 00:00:00 +0000
   2018-03-31 23:00:00 +0000
   2018-04-30 23:00:00 +0000
   2018-05-31 23:00:00 +0000
   2018-06-30 23:00:00 +0000
   2018-07-31 23:00:00 +0000
   2018-08-31 23:00:00 +0000
   2018-09-30 23:00:00 +0000
   2018-11-01 00:00:00 +0000
   2018-12-01 00:00:00 +0000
*/

}

There's an immediate issue here with daylight savings time. That issue can be "fixed" by uncommenting the commented line and forcing the date to be calculated in UTC. I feel as though by forcing it to UTC the dates become invalid when viewing the calendar view in different time zones.

The real question is though: How do I get the first Monday in the week containing the 1st of the month? For example, how do I get Monday 29th February, or Monday 26th April? (see the macOS screenshot). To get the end of the month, do I just add on 42 days from the start? Or is that naive?

Edit

Thanks to the current answers, but we're still stuck.

The following works, until you take daylight savings time into account:

How to solve the problem:

Solution 1:

OK, this is going to be a long and involved answer (hooray for you!).

Overall you're on the right track but there are a couple of subtle errors going on.

Conceptualizing Dates

It's pretty well-established by now (I hope) that Date represents an instant in time. What's not quite as well-established is the inverse. A Date represents an instant, but what represents a calendar value?

When you think about it, a "day" or an "hour" (or even a "month") are a range. They're values that represent all possible instants between a start instant and an end instant.

For this reason, I find it most helpful to think about ranges when I'm dealing with calendar values. In other words by asking "what is the range for this minute/hour/day/week/month/year?" etc.

With this, I think you'll find it's easier to understand what's going on. You understand a Range<Int> already, right? So a Range<Date> is similarly intuitive.

Fortunately, there's API on Calendar to get the range. Unfortunately we don't quite get to just use Range<Date> because of holdover API from the Objective-C days. But we get NSDateInterval, which is effectively the same thing.

You ask for ranges by asking the Calendar for the range of the hour/day/week/month/year that contains a particular instant, like this:

let now = Date()
let calendar = Calendar.current

let today = calendar.dateInterval(of: .day, for: now)

Armed with this, you can get the range of just about anything:

let units = [Calendar.Component.second, .minute, .hour, .day, .weekOfMonth, .month, .quarter, .year, .era]

for unit in units {
    let range = calendar.dateInterval(of: unit, for: now)
    print("Range of \(unit): \(range)")
}

At the time I'm writing this, it prints:

Range of second: Optional(2018-10-04 19:50:10 +0000 to 2018-10-04 19:50:11 +0000)
Range of minute: Optional(2018-10-04 19:50:00 +0000 to 2018-10-04 19:51:00 +0000)
Range of hour: Optional(2018-10-04 19:00:00 +0000 to 2018-10-04 20:00:00 +0000)
Range of day: Optional(2018-10-04 06:00:00 +0000 to 2018-10-05 06:00:00 +0000)
Range of weekOfMonth: Optional(2018-09-30 06:00:00 +0000 to 2018-10-07 06:00:00 +0000)
Range of month: Optional(2018-10-01 06:00:00 +0000 to 2018-11-01 06:00:00 +0000)
Range of quarter: Optional(2018-10-01 06:00:00 +0000 to 2019-01-01 07:00:00 +0000)
Range of year: Optional(2018-01-01 07:00:00 +0000 to 2019-01-01 07:00:00 +0000)
Range of era: Optional(0001-01-03 00:00:00 +0000 to 139369-07-19 02:25:04 +0000)

You can see for the ranges of the respective units getting displayed. There are two things to point out here:

  1. This method returns an optional (DateInterval?), because there might be weird situations where it fails. I can't think of any off the top of my head, but calendars are fickle things, so heads up about this.

  2. Asking for an era's range is usually a nonsensical operation, even though eras are vital to properly constructing dates. It's difficult to have much precision with eras outside of certain calendars (primarily the Japanese calendar), so that value saying "The Common Era (CE or AD) started on January 3rd of the year 1" is probably wrong, but we can't really do anything about that. Also, of course, we have no idea when the current era will end, so we just assume it goes on forever.

Printing Date values

This brings me to the next point, about printing dates. I noticed this in your code and I think it might be the source of some of your issues. I applaud your efforts to account for the abomination that is Daylight Saving Time, but I think you're getting caught up on something that isn't actually a bug, but is this:

Dates always print in UTC.

Always always always.

This is because they are instants in time, and they are the same instant regardless of whether you're in California or Djibouti or Khatmandu or whatever. A Date value does not have a timezone. It's really just an offset from another known point in time. However, printing 560375786.836208 as a Date's description doesn't seem that useful to humans, so the creators of the API made it print as a Gregorian date relative to the UTC timezone.

If you want to print a date, even for debugging, you're almost definitely going to need a DateFormatter.

DateComponents.date

This isn't a problem with your code per-say, but is more of a heads up. The .date property on DateComponents does not guarantee to return the first instant that matches the components specified. It does not guarantee to return the last instant that matches the components. All it does is guarantee that, if it can find an answer, it will give you a Date somewhere in the range of the specified units.

I don't think you're technically relying on this in your code, but it's a good thing to be aware of, and I've seen this misunderstanding causing bugs in software.


With all of this in mind, let's take a look at what you're wanting to do:

  1. You want to know the range of the year
  2. You want to know all of the months in the year
  3. You want to know all of the days in a month
  4. You want to know the week that contains the first day of the month

So, based on what we've learned about ranges, this should be pretty straight-forward now, and interestingly, we can do it all without needing a single DateComponents value. In the code below, I'll be force-unwrapping values for brevity. In your actual code you'll probably want to have better error handling.

The range of the year

This is easy:

let yearRange = calendar.dateInterval(of: .year, for: now)!
The months in the year

This is a little bit more complicated, but not too bad.

var monthsOfYear = Array()
var startOfCurrentMonth = yearRange.start
repeat {
    let monthRange = calendar.dateInterval(of: .month, for: startOfCurrentMonth)!
    monthsOfYear.append(monthRange)
    startOfCurrentMonth = monthRange.end.addingTimeInterval(1)
} while yearRange.contains(startOfCurrentMonth)

Basically, we're going to start with the first instant of the year and ask the calendar for the month that contains that instant. Once we've got that, we'll get the final instant of that month and add one second to it to (ostensibly) shift it into the next month. Then we'll get the range of the month that contains that instant, and so on. Repeat until we have a value that is no longer in the year, which means we've aggregated all of the month ranges in the initial year.

When I run this on my machine, I get this result:

[
    2018-01-01 07:00:00 +0000 to 2018-02-01 07:00:00 +0000, 
    2018-02-01 07:00:00 +0000 to 2018-03-01 07:00:00 +0000, 
    2018-03-01 07:00:00 +0000 to 2018-04-01 06:00:00 +0000, 
    2018-04-01 06:00:00 +0000 to 2018-05-01 06:00:00 +0000, 
    2018-05-01 06:00:00 +0000 to 2018-06-01 06:00:00 +0000, 
    2018-06-01 06:00:00 +0000 to 2018-07-01 06:00:00 +0000, 
    2018-07-01 06:00:00 +0000 to 2018-08-01 06:00:00 +0000, 
    2018-08-01 06:00:00 +0000 to 2018-09-01 06:00:00 +0000, 
    2018-09-01 06:00:00 +0000 to 2018-10-01 06:00:00 +0000, 
    2018-10-01 06:00:00 +0000 to 2018-11-01 06:00:00 +0000, 
    2018-11-01 06:00:00 +0000 to 2018-12-01 07:00:00 +0000, 
    2018-12-01 07:00:00 +0000 to 2019-01-01 07:00:00 +0000
]

Again, it's important to point out here that these dates are in UTC, even though I am in the Mountain Timezone. Because of that, the hour shifts between 07:00 and 06:00, because the Mountain Timezone is either 7 or 6 hours behind UTC, depending on whether we've observing DST. So these values are accurate for my calendar's timezone.

The days in a month

This is just like the previous code:

var daysInMonth = Array()
var startOfCurrentDay = currentMonth.start
repeat {
    let dayRange = calendar.dateInterval(of: .day, for: startOfCurrentDay)!
    daysInMonth.append(dayRange)
    startOfCurrentDay = dayRange.end.addingTimeInterval(1)
} while currentMonth.contains(startOfCurrentDay)

And when I run this, I get this:

[
    2018-10-01 06:00:00 +0000 to 2018-10-02 06:00:00 +0000, 
    2018-10-02 06:00:00 +0000 to 2018-10-03 06:00:00 +0000, 
    2018-10-03 06:00:00 +0000 to 2018-10-04 06:00:00 +0000, 
    ... snipped for brevity ...
    2018-10-29 06:00:00 +0000 to 2018-10-30 06:00:00 +0000, 
    2018-10-30 06:00:00 +0000 to 2018-10-31 06:00:00 +0000, 
    2018-10-31 06:00:00 +0000 to 2018-11-01 06:00:00 +0000
]
The week containing a month's start

Let's return to that monthsOfYear array we created above. We'll use that to figure out the weeks for each month:

for month in monthsOfYear {
    let weekContainingStart = calendar.dateInterval(of: .weekOfMonth, for: month.start)!
    print(weekContainingStart)
}

And for my timezone, this prints:

2017-12-31 07:00:00 +0000 to 2018-01-07 07:00:00 +0000
2018-01-28 07:00:00 +0000 to 2018-02-04 07:00:00 +0000
2018-02-25 07:00:00 +0000 to 2018-03-04 07:00:00 +0000
2018-04-01 06:00:00 +0000 to 2018-04-08 06:00:00 +0000
2018-04-29 06:00:00 +0000 to 2018-05-06 06:00:00 +0000
2018-05-27 06:00:00 +0000 to 2018-06-03 06:00:00 +0000
2018-07-01 06:00:00 +0000 to 2018-07-08 06:00:00 +0000
2018-07-29 06:00:00 +0000 to 2018-08-05 06:00:00 +0000
2018-08-26 06:00:00 +0000 to 2018-09-02 06:00:00 +0000
2018-09-30 06:00:00 +0000 to 2018-10-07 06:00:00 +0000
2018-10-28 06:00:00 +0000 to 2018-11-04 06:00:00 +0000
2018-11-25 07:00:00 +0000 to 2018-12-02 07:00:00 +0000

One thing you'll notice here is that the this has Sunday as the first day of the week. In your screenshot, for example, you have the week containing Feburary 1st starting on the 29th.

That behavior is governed by the firstWeekday property on Calendar. By default, that value is probably 1 (depending on your locale), indicating that weeks start on Sunday. If you want your calendar to do computations where a week starts on Monday, then you'll change the value of that property to 2:

var weeksStartOnMondayCalendar = calendar
weeksStartOnMondayCalendar.firstWeekday = 2

for month in monthsOfYear {
    let weekContainingStart = weeksStartOnMondayCalendar.dateInterval(of: .weekOfMonth, for: month.start)!
    print(weekContainingStart)
}

Now when I run that code, I see that the week containing Feburary 1st "starts" on January 29th:

2018-01-01 07:00:00 +0000 to 2018-01-08 07:00:00 +0000
2018-01-29 07:00:00 +0000 to 2018-02-05 07:00:00 +0000
2018-02-26 07:00:00 +0000 to 2018-03-05 07:00:00 +0000
2018-03-26 06:00:00 +0000 to 2018-04-02 06:00:00 +0000
2018-04-30 06:00:00 +0000 to 2018-05-07 06:00:00 +0000
2018-05-28 06:00:00 +0000 to 2018-06-04 06:00:00 +0000
2018-06-25 06:00:00 +0000 to 2018-07-02 06:00:00 +0000
2018-07-30 06:00:00 +0000 to 2018-08-06 06:00:00 +0000
2018-08-27 06:00:00 +0000 to 2018-09-03 06:00:00 +0000
2018-10-01 06:00:00 +0000 to 2018-10-08 06:00:00 +0000
2018-10-29 06:00:00 +0000 to 2018-11-05 07:00:00 +0000
2018-11-26 07:00:00 +0000 to 2018-12-03 07:00:00 +0000

Final Thoughts

With all of this, you have all the tools you'll need to build the same UI as Calendar.app shows.

As an added bonus, all of the code I've posted here works regardless of your calendar, timezone, and locale. It'll work to build UIs for the Japanese calendar, the Coptic calendars, the Hebrew calendar, ISO8601, etc. It'll work in any timezone, and it'll work in any locale.

Additionally, having all the DateInterval values like this will likely make implementing your app easier, because doing a "range contains" check is the sort of check you want to be doing when making a calendar app.

The only other thing is to make sure you use DateFormatter when rendering these values into a String. Please don't pull out the individual date components.

If you have follow-up questions, post them as new questions and @mention me in a comment.

Solution 2:

Looking through some code I wrote a few years ago to do something similar, it went something likeā€¦

  • Get the date of first day of the month
  • Get the dayNumber of that day (CalendarUnit.weekday)
  • Number of days of previous week to show = dayNumber - 1
  • Subtract that from the first day to get the calendar start date

Solution 3:

I have the answer to the first question:

First question: how do I now get the first Monday in the week containing the 1st of the month? For example, how do I get Monday 29th February, or Monday 26th April? (see the macOS screenshot). To get the end of the month, do I just add on 42 days from the start? Or is that naive?

let calendar = Calendar(identifier: .iso8601)
    func firstOfMonth(month: Int, year: Int) -> Date {



        var firstOfMonthComponents = DateComponents()
        firstOfMonthComponents.calendar = calendar
        firstOfMonthComponents.year = year
        firstOfMonthComponents.month = month
        firstOfMonthComponents.day = 01

        return firstOfMonthComponents.date!
    }

    var aug01 = firstOfMonth(month: 08, year: 2018)
    let dayOfWeek = calendar.component(.weekday, from: aug01)
    if dayOfWeek <= 4 { //thursday or earlier
         //take away dayOfWeek days from aug01
    else {
         //add 7-dayOfWeek days to aug01
    }

Hope this helps!