How to render a basic calendar UI in Elm

Jul 31, 2023

The beauty of a language like Elm (and other lambda-calculus / functional programming inspired languages) is that there's very little transformation involved in going from an idea to code. And that seems to have a big impact on getting things done.

Making a basic calendar UI turned out to be a great example of this.

Here's the final output I aimed for:

final output example

I started by thinking about the lowest unit: the month.

Given a month (and a year), can I get this?

month render

My first idea was to do this:

The type of data we choose should be good enough to make it possible to render it easily.

So, in our case, we're rendering dates. Lists of dates.

And because we're rendering "rows" of dates, each row is a week of dates.

So the data structure I'm going for is this:

-- assuming `Date` is some valid date representation
type Week = List Date
type MonthData = List Week

MonthData could have 5-6 items, each item being a Week. And each Week being a list of 7 Dates.

I took a look at Elm's time library to see if that fit the bill. Turns out it didnt. It's too low-level and involves a lot of Task mechanics that was an overkill.

Looking around, I found Justin's date library which seemed like a great candidate.

(Edit: In fact, it turned out to be a life-saver. It has everything we need.)

Justin's date library had these two functions which were interesting:

ceiling : Interval -> Date -> Date
-- Round up a date to the beginning of the closest interval. The resulting date will be greater than or equal to the one provided.

floor : Interval -> Date -> Date
-- Round down a date to the beginning of the closest interval. The resulting date will be less than or equal to the one provided.

So, if I wanted to find the nearest "previous" Sunday before 1st July 2023, I can do this:

import Date
import Time

result = Date.floor Date.Sunday (Date.fromCalendarDate 2023 Time.Jul 1)

Testing this in REPL:

> result |> Date.format "EEE, d MM y"
"Sun, 25 Jun 2023" : String

And if I wanted to nearest "next" Saturday after 31st of July 2023, I can do this:

import Date
import Time

result = Date.ceiling Date.Saturday (Date.fromCalendarDate 2023 Time.Jul 31)

In REPL:

> result |> format "EEE, d MMM y"
"Sat, 5 Aug 2023" : String

That's fantastic. Now, my logic is simplified to this:

Getting the start date for a given month, year and start of week:

First step: take month, year and start of week and output the right/proper start date.

Example what we want:

-- `getProperStartDate : StartOfWeek -> Month -> Year -> Date`
getProperStartDate Sunday July 2023 == "25th June 2023"
getProperStartDate Sunday June 2023 == "28th May 2023"

-- ignore the fact that the result is string. that's just for demonstration

To get here, we just have to use the floor function from the Date library:

import Date
import Time

getProperStartDate : StartOfWeek -> Month -> Year -> Date.Date
getProperStartDate startOfWeek month year =
    Date.floor (weekdayToInterval startOfWeek) (Date.fromCalendarDate year month 1)

-- we also need a function that converts a Time.Weekday to a Date.Interval
-- to use in the `getProperStartDate` function
weekdayToInterval : Time.Weekday -> Date.Interval
weekdayToInterval weekday =
    case weekday of
        Time.Sun ->
            Date.Sunday

        Time.Mon ->
            Date.Monday

        Time.Tue ->
            Date.Tuesday

        Time.Wed ->
            Date.Wednesday

        Time.Thu ->
            Date.Thursday

        Time.Fri ->
            Date.Friday

        Time.Sat ->
            Date.Saturday

Test in REPL:

> getProperStartDate Time.Sun Time.Jul 2023 |> Date.format "EEE, d MMM y"
"Sun, 25 Jun 2023" : String

Next up, let's also write a function to get the proper end date for a given month, year and start of week.

This time, it's not as straight-forward.

Take 31st July 2023 and Sunday (for start of week) as an example:

And here's that logic:

getProperEndDate : StartOfWeek -> Month -> Year -> Date.Date
getProperEndDate startOfWeek month year =
    let
        endDate =
            Date.add Date.Months 1 (Date.fromCalendarDate year month 1) |> Date.add Date.Days -1

        endDateIsStartOfWeek =
            Date.weekday endDate == startOfWeek
    in
    if endDateIsStartOfWeek then
        Date.add Date.Days 7 endDate

    else
        Date.ceiling (weekdayToInterval startOfWeek) endDate |> Date.add Date.Days -1

Testing this in REPL:

> getProperEndDate Time.Sun Time.Jul 2023 |> format "EEE, d MMM y"
"Sat, 5 Aug 2023" : String

Now that we know the proper start and end dates, we can use them to get all dates in that range.

getDatesBetween : Date.Date -> Date.Date -> List Date.Date
getDatesBetween start end =
    Date.range Date.Day 1 start (Date.add Date.Days 1 end)

This Date.range function excludes the last date in the range. But we need the proper end date as well, so we just add one.

And now that we have all the dates to render, we can group them to get a list of weeks!

To do this, I'm using the List.Extra library's groupsOf function:

getMonth : List Date.Date -> List Week
getMonth =
    List.Extra.groupsOf 7

And of course, we need to take just month, year and start of week as inputs and get back an entire month of dates:

getDatesForMonth : Month -> Year -> List Week
getDatesForMonth month year =
    let
        start =
            getProperStartDate Time.Sun month year

        end =
            getProperEndDate Time.Sun month year

        dates =
            getDatesBetween start end
    in
    getMonth dates

Yes, we're just hard-coding the start of the week (Time.Sun) for now. We can switch this later to be something that the function accepts as an input.

Testing these in REPL:

> getDatesForMonth Time.Jul 2023
[[RD 738696,RD 738697,RD 738698,RD 738699,RD 738700,RD 738701,RD 738702],[RD 738703,RD 738704,RD 738705,RD 738706,RD 738707,RD 738708,RD 738709],[RD 738710,RD 738711,RD 738712,RD 738713,RD 738714,RD 738715,RD 738716],[RD 738717,RD 738718,RD 738719,RD 738720,RD 738721,RD 738722,RD 738723],[RD 738724,RD 738725,RD 738726,RD 738727,RD 738728,RD 738729,RD 738730],[RD 738731,RD 738732,RD 738733,RD 738734,RD 738735,RD 738736,RD 738737]]

The RD Int is a native representation of the Date library.

We can format this to be human-friendly and check that the results are OK:

> getDatesForMonth Time.Jul 2023 |> List.map (List.map (Date.format "EEE, d MMM y"))
[["Sun, 25 Jun 2023","Mon, 26 Jun 2023","Tue, 27 Jun 2023","Wed, 28 Jun 2023","Thu, 29 Jun 2023","Fri, 30 Jun 2023","Sat, 1 Jul 2023"],["Sun, 2 Jul 2023","Mon, 3 Jul 2023","Tue, 4 Jul 2023","Wed, 5 Jul 2023","Thu, 6 Jul 2023","Fri, 7 Jul 2023","Sat, 8 Jul 2023"],["Sun, 9 Jul 2023","Mon, 10 Jul 2023","Tue, 11 Jul 2023","Wed, 12 Jul 2023","Thu, 13 Jul 2023","Fri, 14 Jul 2023","Sat, 15 Jul 2023"],["Sun, 16 Jul 2023","Mon, 17 Jul 2023","Tue, 18 Jul 2023","Wed, 19 Jul 2023","Thu, 20 Jul 2023","Fri, 21 Jul 2023","Sat, 22 Jul 2023"],["Sun, 23 Jul 2023","Mon, 24 Jul 2023","Tue, 25 Jul 2023","Wed, 26 Jul 2023","Thu, 27 Jul 2023","Fri, 28 Jul 2023","Sat, 29 Jul 2023"],["Sun, 30 Jul 2023","Mon, 31 Jul 2023","Tue, 1 Aug 2023","Wed, 2 Aug 2023","Thu, 3 Aug 2023","Fri, 4 Aug 2023","Sat, 5 Aug 2023"]]

Now that I have this data structure, all I need to do is render it as a month!

We can work this inside-out. That is, we can build functions to render a date, a week and then combine these to render the month.

Here's a function to render the date:

(I'm using Tailwind classes to simplify styling)

viewDate : Date.Date -> Html Msg
viewDate date =
    H.div [ Attr.class "flex items-center justify-center" ] [ H.text <| Date.format "d" date ]

And the week:

viewWeek : Week -> Html Msg
viewWeek dates =
    H.div [ Attr.class "grid grid-cols-7 items-center gap-4" ] (List.map viewDate dates)

I'm using CSS grid to make it easy to arrange the dates. Each date is flex and center-aligned (see the viewDate function above). And the week-render takes care of rendering all dates in a 7-column grid.

And the view month is just this:

viewMonth : List Week -> Html Msg
viewMonth weeks =
    H.div [] (List.map viewWeek weeks)

And of course, we need to render the week header as well which lists the weekdays.

To do this, I'm going to be a bit hacky:

Expressed in code, we start with the week header view function which takes a list of weeks and renders a list of weekday headers.

viewWeekHeader : Week -> Html Msg
viewWeekHeader week =
    H.div [ Attr.class "grid grid-cols-7 items-center gap-2" ] <|
        List.map (\date -> H.div [ Attr.class "flex items-center justify-center" ] [ H.text <| Date.format "EEEEE" date ]) week

Combining all of this into a view function:

view : Model -> Html Msg
view _ =
    let
        dates =
            getDatesForMonth Time.Jul 2023
    in
    H.div [ Attr.class "w-72" ]
        [ viewWeekHeader (Maybe.withDefault [] (List.head dates))
        , viewMonth dates
        ]

Here's what it renders as:

month render ugly

It's ugly, shows dates from the previous/next months and there's so much room for improvement.

But we've got the basics right and that's good enough to boot.

The first order of business now is to not show dates which are not part of the month.

In the example above, that's 25th - 30th (June) and 1st - 5th (August).

What we have is a long list of dates. We need to somehow know if a date in the list is part of the current month (eg July) or not.

Let's think in terms of the type:

type alias CalendarDate =
    { date : Date.Date, dateInCurrentMonth : Bool }

And we'll change our week to be:

type alias Week =
    List CalendarDate

The moment we make this change, the Elm compiler will start guiding us through the functions that need updating.

We'll change the getMonth one first:

getMonth : List CalendarDate -> List Week
getMonth =
    List.Extra.groupsOf 7

And:

getDatesForMonth : Month -> Year -> List Week
getDatesForMonth month year =
    let
        start =
            getProperStartDate Time.Sun month year

        end =
            getProperEndDate Time.Sun month year

        dates =
            getDatesBetween start end
                |> List.map (\date -> { date = date, dateInCurrentMonth = Date.month date == month })
    in
    getMonth dates

And the last things we need to fix are the viewDate and viewWeekHeader functions. The logic is simple: if the date is not that of current month, we'll set the opacity to 0.

viewDate : CalendarDate -> Html Msg
viewDate { date, dateInCurrentMonth } =
    H.div
        [ Attr.class "flex items-center justify-center"
        , Attr.class
            (if dateInCurrentMonth then
                ""

             else
                "opacity-0"
            )
        ]
        [ H.text <| Date.format "d" date ]

viewWeekHeader : Week -> Html Msg
viewWeekHeader week =
    H.div [ Attr.class "grid grid-cols-7 items-center gap-2" ] <|
        List.map (\{ date } -> H.div [ Attr.class "flex items-center justify-center" ] [ H.text <| Date.format "EEEEE" date ]) week

This gets us to here:

refining month render

One of the things that's been bothering me about this render is that there's a lot of duplication because of the way we're rendering the rows.

Each row is it's own "grid", instead of the whole month being a grid. (And the week header is also it's own "grid").

too many divs

We can fix this.

We want a structure like this:

<div class="grid grid-cols-7 items-center gap-2">
	<div>S</div>
	<div>M</div>
	<div>T</div>
	... all weekday headers
	...
	<div>1</div>
	<div>2</div>
	... all dates
</div>

We can do this in two steps:

  1. first, we'll write one generic container viewBox which wraps all its children in the grid
  2. then, we'll ensure all our viewMonth/viewWeek functions return a list of divs that we can just render inside the viewBox
viewBox : List (Html Msg) -> Html Msg
viewBox =
    H.div [ Attr.class "grid grid-cols-7 gap-2 items-center" ]

And to make life easier, I'll also add a viewItem (which is basically the date render):

viewItem : List (Html Msg) -> Html Msg
viewItem =
    H.div [ Attr.class "flex items-center justify-center" ]

And now, we'll change the other view functions so they return a List (Html Msg) instead of Html Msg:

viewWeek : Week -> List (Html Msg)
viewWeek dates =
    List.map viewDate dates


viewMonth : List Week -> List (Html Msg)
viewMonth weeks =
    List.concatMap viewWeek weeks


viewWeekHeader : Week -> List (Html Msg)
viewWeekHeader week =
    List.map (\{ date } -> H.div [ Attr.class "flex items-center justify-center" ] [ H.text <| Date.format "EEEEE" date ]) week

In the viewMonth function, we use the List.concatMap function because:

Finally, we'll modify the main view function:

view : Model -> Html Msg
view _ =
    let
        year =
            2023

        month =
            Time.Jul

        dates =
            getDatesForMonth month year
    in
    H.div [ Attr.class "w-72" ]
        [ H.div [ Attr.class "p-2" ] [ H.text (Date.format "MMMM YYYY" (Date.fromCalendarDate year month 1)) ]
        , viewBox <| List.concat [ viewWeekHeader (Maybe.withDefault [] (List.head dates)), viewMonth dates ]
        ]

The main change is that we're now using the viewBox function (so we modify the input to it).

And the other thing is we added this bit:

H.div [ Attr.class "p-2" ] [ H.text (Date.format "MMMM YYYY" (Date.fromCalendarDate year month 1))

which adds a month-year header.

Our final result:

month final

All that remains is to repeat this for each month in a given year.

To do this, we can just write out all the months of a year, and loop over them:

view : Model -> Html Msg
view _ =
    let
        year =
            2023

        months =
            [ Time.Jan
            , Time.Feb
            , Time.Mar
            , Time.Apr
            , Time.May
            , Time.Jun
            , Time.Jul
            , Time.Aug
            , Time.Sep
            , Time.Oct
            , Time.Nov
            , Time.Dec
            ]
    in
    H.div [ Attr.class "p-8 grid grid-cols-4 gap-4 items-stretch" ]
        (List.map (\month -> viewMonthBox month year) months)

This produces:

full year but with bug

Oh no! Here's a problem: the first month reads January 2022.

Turns out, the formatting string I was using is wrong:

Instead of MMMM YYYY, I need to be using MMMM y. (More info about these format strings here.)

[ H.div [ Attr.class "p-2 text-center" ] [ H.text (Date.format "MMMM y" (Date.fromCalendarDate year month 1)) ]

And that fixes the problem:

full year final

The full source code can be found here.