Tutorial

Create a Reusable Calendar in Phoenix LiveView

This post was updated 29 Nov - 2022

Phoenix 1.7 calendar liveview phoenix

In this updated tutorial for Phoenix 1.7, I am creating a reusable calendar in Phoenix LiveView. The calendar should be able to switch month, highlight current day and select a specific day. I might extend functionality in a future tutorial. And also, all styling are done with Tailwind.

And I do it in ~100 lines of code and 0 lines of Javascript and 0 lines of CSS

STEP 1 - Create Calendar component

Since I already know I want to do this in LiveView so I will go ahead and create a new component called TutorialWeb.Live.CalendarComponent .

The first step is so make it display current month and current date. I also want to make it configurable if the week starts with Sunday or Monday

defmodule TutorialWeb.Live.CalendarComponent do
  use Phoenix.LiveComponent

  @week_start_at :monday

  def render(assigns) do
    ~H"""
    <div>
      <table>
        <thead>
          <tr>
            <th :for={week_day <- List.first(@week_rows)}>
              <%= Calendar.strftime(week_day, "%a") %>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr :for={week <- @week_rows}>
            <td :for={day <- week}>
              <time datetime={Calendar.strftime(day, "%Y-%m-%d")}><%= Calendar.strftime(day, "%d") %></time>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    """
  end

  def mount(socket) do
    current_date = Date.utc_today()

    assigns = [
      current_date: current_date,
      selected_date: nil,
      week_rows: week_rows(current_date)
    ]

    {:ok, assign(socket, assigns)}
  end

  defp week_rows(current_date) do
    first =
      current_date
      |> Date.beginning_of_month()
      |> Date.beginning_of_week(@week_start_at)

    last =
      current_date
      |> Date.end_of_month()
      |> Date.end_of_week(@week_start_at)

    Date.range(first, last)
    |> Enum.map(& &1)
    |> Enum.chunk_every(7)
  end
end

The most complex part here is the week_rows/1 function. It takes the current_date as an argument, which is the first day of the current month. And from there take the start and end date for the month as well as the start date and end date for those weeks. That gives the dates for the calendar that works for previous and next month.

That ends with taking a a date range and split it up in parts of seven to get the all the dates in a list.

After that, the rendering of the table in the template is pretty basic.

I iterate over the list of day names to get the headers and then iterate first over the list of weeks, and then within a week row, I iterate over the actual days.

<br>

<br>

When that is done, I want to attach some interactivity to the calendar.

STEP 2 - Add month selection

The calendar are pretty useless without being able to switch months. So that would be the next thing to add. Go to the render function again and in the top, add:

<div>
  <h3><%= Calendar.strftime(@current_date, "%B %Y") %></h3>
  <div>
    <button type="button" phx-target={@myself} phx-click="prev-month">&laquo; Prev</button>
    <button type="button" phx-target={@myself} phx-click="next-month">Next &raquo;</button>
  </div>
</div>

Note that I add phx-click="prev-month" to the links. So I also need to add handlers in the LiveView component.

So what I do is take current data and shift the month with either +1 or -1 . Then I recalculate the weeks_rows based on the new date.

def handle_event("prev-month", _, socket) do
  new_date = socket.assigns.current_date |> Date.beginning_of_month() |> Date.add(-1)

  assigns = [
    current_date: new_date,
    week_rows: week_rows(new_date)
  ]

  {:noreply, assign(socket, assigns)}
end

def handle_event("next-month", _, socket) do
  new_date = socket.assigns.current_date |> Date.end_of_month() |> Date.add(1)

  assigns = [
    current_date: new_date,
    week_rows: week_rows(new_date)
  ]

  {:noreply, assign(socket, assigns)}
end

STEP 3 - Add functionality to a single day

A single day should have some conditional logic. It should be able to indicate and handle:

  1. Display todays date with a specific color
  2. Display other months dates with a gray background
  3. Make a date selectable for future functionality
  4. Display events for this date (Not covered in this tutorial)

The specific HEEX markup for a day look like this:

<td :for={day <- week} class={[
  "text-center",
  today?(day) && "bg-green-100",
  other_month?(day, @current_date) && "bg-gray-100",
  selected_date?(day, @selected_date) && "bg-blue-100"
]}>
  <button type="button" phx-target={@myself} phx-click="pick-date" phx-value-date={Calendar.strftime(day, "%Y-%m-%d")}>
    <time datetime={Calendar.strftime(day, "%Y-%m-%d")}><%= Calendar.strftime(day, "%d") %></time>
  </button>
</td>

And the helper methods for figuring out if the day is selected, today or belongs to another month than the current one.

defp selected_date?(day, selected_date), do: day == selected_date

defp today?(day), do: day == Date.utc_today()

defp other_month?(day, current_date), do: Date.beginning_of_month(day) != Date.beginning_of_month(current_date)

Now that each day has a button, the desired behaviour is to make the day selected when the button is clicked.

That code looks like phx-target={@myself} phx-click="pick-date" phx-value-date={Calendar.strftime(day, "%Y-%m-%d")}

The code that handles that in the Live Component looks like this:

def handle_event("pick-date", %{"date" => date}, socket) do
  {:noreply, assign(socket, :selected_date, Date.from_iso8601!(date))}
end

This expect date to have the format of "2022-02-02".

Final code

The final example looks like th

defmodule TutorialWeb.Live.CalendarComponent do
  use Phoenix.LiveComponent

  @week_start_at :monday

  def render(assigns) do
    ~H"""
    <div>
      <div>
        <h3><%= Calendar.strftime(@current_date, "%B %Y") %></h3>
        <div>
          <button type="button" phx-target={@myself} phx-click="prev-month">&laquo; Prev</button>
          <button type="button" phx-target={@myself} phx-click="next-month">Next &raquo;</button>
        </div>
      </div>

      <table>
        <thead>
          <tr>
            <th :for={week_day <- List.first(@week_rows)}>
              <%= Calendar.strftime(week_day, "%a") %>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr :for={week <- @week_rows}>
            <td :for={day <- week} class={[
              "text-center",
              today?(day) && "bg-green-100",
              other_month?(day, @current_date) && "bg-gray-100",
              selected_date?(day, @selected_date) && "bg-blue-100"
            ]}>
              <button type="button" phx-target={@myself} phx-click="pick-date" phx-value-date={Calendar.strftime(day, "%Y-%m-%d")}>
                <time datetime={Calendar.strftime(day, "%Y-%m-%d")}><%= Calendar.strftime(day, "%d") %></time>
              </button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    """
  end

  def mount(socket) do
    current_date = Date.utc_today()

    assigns = [
      current_date: current_date,
      selected_date: nil,
      week_rows: week_rows(current_date)
    ]

    {:ok, assign(socket, assigns)}
  end

  defp week_rows(current_date) do
    first =
      current_date
      |> Date.beginning_of_month()
      |> Date.beginning_of_week(@week_start_at)

    last =
      current_date
      |> Date.end_of_month()
      |> Date.end_of_week(@week_start_at)

    Date.range(first, last)
    |> Enum.map(& &1)
    |> Enum.chunk_every(7)
  end

  def handle_event("prev-month", _, socket) do
    new_date = socket.assigns.current_date |> Date.beginning_of_month() |> Date.add(-1)

    assigns = [
      current_date: new_date,
      week_rows: week_rows(new_date)
    ]

    {:noreply, assign(socket, assigns)}
  end

  def handle_event("next-month", _, socket) do
    new_date = socket.assigns.current_date |> Date.end_of_month() |> Date.add(1)

    assigns = [
      current_date: new_date,
      week_rows: week_rows(new_date)
    ]

    {:noreply, assign(socket, assigns)}
  end

  def handle_event("pick-date", %{"date" => date}, socket) do
    {:noreply, assign(socket, :selected_date, Date.from_iso8601!(date))}
  end

  defp selected_date?(day, selected_date), do: day == selected_date

  defp today?(day), do: day == Date.utc_today()

  defp other_month?(day, current_date), do: Date.beginning_of_month(day) != Date.beginning_of_month(current_date)
end

From this, it should be easy to get going with a customized real world example.

Related Tutorials

Published 04 May - 2021
Updated 05 May - 2022

How to combine Phoenix LiveView with Alpine.js

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..

Published 23 Dec - 2020

Getting Started with Phoenix and LiveView