HTMX Event Calendar

  • Jonathan Lahijani

One of the key features of the Geffen Playhouse website was an interactive event calendar that would list all of the past, present and future performances in a monthly calendar.

Geffen Playhouse Event Calendar
Geffen Playhouse Event Calendar

Requirements

The requirements of the Geffen Playhouse event calendar were:

  1. Monthly view only.
  2. Monthly view on large screens must display the performances inline (large mode).
  3. Monthly view on mobile screens must display the performances inside a modal after the date is clicked (small mode).
  4. Monthly view on show detail pages must display the calendar in small mode since it in contained within a thin sidebar.
  5. A date must list the shows in order of shows first, then performances for each show.
  6. Individual performances should have additional meta data associated with them. For example, a performance can be assigned the "Opening Night" performance type. Furthermore, a performance can be assigned different accessibility options, such as "Low Vision Captioning", which would be indicated by an icon next to the performance time on the calendar.
  7. Ability to embed calendar in different contexts. For example, on a general page promoting a specific show with all months displayed that contained performances, without the need to use the previous / next month navigation. (this need came up much later)

The initial approach I took for this event calendar involved using a well-established calendar library and adapting it to the requirements of the site. FullCalendar quickly became the top contender, and while it offered many other features that would not be utilized, such as the ability to add events to a calendar from the frontend or drag-and-drop events like a personal calendar software, the initial Geffen Playhouse event calendar was powered by FullCalendar v4. A custom end-point was created within ProcessWire that FullCalendar would call on when rendering each calendar day. The end-point did not simply deliver JSON data of each performance, but instead passed HTML so that each date would be rendered according to Geffen Playhouse's calendar guidelines. While this felt like a hack and went against the way FullCalendar was "expected" to receive event content, it worked nevertheless. Custom CSS was created to style it according to the Geffen Playhouse calendar design mockups.

Major Changes Between Releases

After launching the Geffen Playhouse website, I regularly updated the various frontend packages the site relied on without issues, however it wasn't long until FullCalendar released v5, which was a significant shift from v4 (at least from my perspective). As a result, there were major breaking changes with some workarounds I had done that were required based on the Geffen Calendar feature specifications. This forced me to lock the package to v4 and at some point, rethink my approach further as I was not completely satisfied with the cleanliness and maintainability of the initial integration.

Rethinking My Approach

Like many other calendar libraries, FullCalendar is a JavaScript-based solution and navigating from one month to another didn't require a full page load (thanks to AJAX) which would benefit the user experience. Of course, it takes quite of bit of JavaScript under the hood to provide that single-page app (SPA) experience which makes adopting a ready-to-go package like FullCalendar enticing. However for such a key feature of Geffen Playhouse (the gateway to the ticket purchasing flow), I didn't want to have to continuously follow the changes of FullCalendar and re-adapt Geffen Playhouse's requirements with every major/minor release.

Furthermore, the Geffen Playhouse website is a traditional, server-side rendered site and it using a library that operates primarily on the client-side felt out of place. So the question became: How can I develop a calendar that (a) looks and works nearly the same way, (b) has the SPA-like feel and (c) that I am in complete control of?

HTMX to the Rescue

In 2021, the HTMX library (and similar approaches like HotWire for Ruby on Rails) started being noticed by developers, especially those fatigued by JavaScript framework churn. The idea of sending HTML over the wire (as opposed to JSON) started gaining wider acceptance. I used HTMX to develop an elaborate CRUD app for Transferware Collectors Club and was extremely satisfied with the results and how truly easy it was to get the benefits of an SPA-like experience using server-side rendering. This was a key moment in my career because I had not adopted a development approach that heavily utilized reactive JavaScript frameworks (React, Angular, Vue). My projects didn't require that (JS sprinkles were more than enough) and doing so meant giving up a level of control on the frontend with solutions that simply seemed to constantly and significantly change every six months. Shortly after the Transferware Collectors Club Patterns Database was completed, I thought about how I could use HTMX for Geffen Playhouse's event calendar as well.

The main challenge was how to render a monthly calendar in PHP with modern HTML and CSS (using CSS Grid, not tables!). Re-inventing the wheel is something I like to avoid (hence why I used FullCalendar to begin with), however in this case, if I could crack this small challenge, then tying it in to HTMX would be straight-forward.

A PHP/HTML/CSS Monthly Grid Calendar

Creating a monthly grid calendar in PHP was straight-forward. Here's a minimal example which covers the "large mode" approach described earlier:


function renderCalendarMonthGrid($year, $month) {

  // prevent invalid months or unreasonable years
  if( $month>12 || $month<1 || $year>2050 || $year<1980 ) return 'Invalid month or year.';

  $out = '';

  // get current month name; ex: April
  $month_name = date("F", mktime(0, 0, 0, $month, 10));

  // get number of days in current month
  if(function_exists('cal_days_in_month')) {
    // requires php calendar extension to be installed
    $days_in_month = cal_days_in_month(CAL_GREGORIAN, $month, $year);
  } else {
    // works without calendar extension
    $days_in_month = date('t', mktime(0, 0, 0, $month, 1, $year));
  }

  // get ISO 8601 numeric representation of the day of the week; 1 = monday ... 7 = sunday
  $first_day_number_of_month = date('N', strtotime("{$year}-{$month}-01"));

  // sunday is the 'first' day of the week (in the US), so re-assign that to 0
  if($first_day_number_of_month==7) $first_day_number_of_month = 0;

  // offset the first day of the month accordingly via css grid
  $grid_column_start = $first_day_number_of_month + 1;

  // determine next month/year
  if($month==12) {
    $next_month = 1;
    $next_year = $year + 1;
  } else {
    $next_month = $month + 1;
    $next_year = $year;
  }

  // determine previous month/year
  if($month==1) {
    $prev_month = 12;
    $prev_year = $year - 1;
  } else {
    $prev_month = $month - 1;
    $prev_year = $year;
  }

  // #calendar div
  $out .= "<div id='calendar' class='calendar'>";

  // calendar heading
  $out .= "<h2 class='calendar-heading'>{$month_name} {$year}</h2>";

  // calendar nav (powered by htmx)
  $out .= "<div class='calendar-nav'>
    <button
      class='calendar-nav-button calendar-nav-button-prev'
      hx-post='/htmx-calendar/?year={$prev_year}&month={$prev_month}'
      hx-trigger='click'
      hx-target='#calendar'
      hx-swap='outerHTML'
    >
      Previous Month
    </button>
    <button
      class='calendar-nav-button calendar-nav-button-next'
      hx-post='/htmx-calendar/?year={$next_year}&month={$next_month}'
      hx-trigger='click'
      hx-target='#calendar'
      hx-swap='outerHTML'
    >
      Next Month
    </button>
  </div><!-- /.calendar-nav -->";

  // calendar weekdays heading row
  $out .= "
    <ul class='calendar-weekdays' aria-hidden='true'>
      <li>
        <abbr title='Sunday'>Sun</abbr>
      </li>
      <li>
        <abbr title='Monday'>Mon</abbr>
      </li>
      <li>
        <abbr title='Tuesday'>Tue</abbr>
      </li>
      <li>
        <abbr title='Wednesday'>Wed</abbr>
      </li>
      <li>
        <abbr title='Thursday'>Thu</abbr>
      </li>
      <li>
        <abbr title='Friday'>Fri</abbr>
      </li>
      <li>
        <abbr title='Saturday'>Sat</abbr>
      </li>
    </ul><!-- /.calendar-weekdays -->
  ";

  // calendar days
  $out .= "<ol class='calendar-month-grid'>";
  for($day = 1; $day <= $days_in_month; $day++) {
    $day_timestamp = strtotime($year."-".$month."-".$day);
    $day_date_id = date("Y-m-d", $day_timestamp);
    $day_title = date("l, F j, Y", $day_timestamp);
    $day_class = 'calendar-day ';

    // consider injecting your own event data here...
    $events = '';

    if(date("Y-m-d")==$day_date_id) $day_class .= 'calendar-day-today';
    $out .= "<li class='{$day_class}'";
    // if it's the first day of the month, offset it accordingly
    if($day==1) $out .= " style='grid-column-start:{$grid_column_start}'";
    $out .= ">";
    // create a div with a unique date id; can use as a hook with processwire markup regions to inject content easily
    $out .= "<div id='date-{$day_date_id}'>";
    // include a nicely formatted day title inside a heading tag for accessibility
    $out .= "<h3 class='day-title visually-hidden'>{$day_title}</h3>";
    // include the day number, but hide for accessibility
    $out .= "<span class='day-number' aria-hidden='true'>{$day}</span>";
    $out .= $events;
    $out .= "</div>";
    $out .= "</li>";
  }
  $out .= "</ol><!-- /.calendar-month-grid -->";

  // end #calendar div
  $out .= "</div><!-- /.calendar -->";

  // output
  return $out;
}

Now comes the CSS, which thanks to CSS Grid Layouts, allows us to have a slick looking calendar (no floats, tables, flexbox or extraneous divs required) with just a few lines of CSS and no silly hacks.


/* utility styles */
.visually-hidden {
  position: absolute;
  clip: rect(1px, 1px, 1px, 1px);
  -webkit-clip-path: inset(0px 0px 99.9% 99.9%);
  clip-path: inset(0px 0px 99.9% 99.9%);
  overflow: hidden;
  height: 1px;
  width: 1px;
  padding: 0;
  border: 0;
}
/* calendar styles */
.calendar {
}
.calendar .calendar-weekdays,
.calendar .calendar-month-grid {
  /* grid styling */
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  /* general styling */
  padding: 0px;
  grid-gap: 6px;
  list-style: none;
}
.calendar .calendar-month-grid .calendar-day {
  /* general styling */
  padding: 10px;
  background-color: #f0f0f0;
  min-height: 100px;
}
.calendar .calendar-month-grid .calendar-day.calendar-day-today {
  background-color: #ffffcc;
}

Make sure to load HTMX in the <head> of the page:


<script src='https://unpkg.com/htmx.org@latest'></script>

Now we can display the calendar by simply calling the function on our page:


<?=renderCalendarMonthGrid(date('Y'), date('m'))?>

The result...

HTMX Event Calendar
HTMX Event Calendar

Integrating with ProcessWire

What follows is how to complete this calendar specifically for ProcessWire, but this code should be easily adaptable to any PHP-based CMS. While not necessary for this specific example, consider adding the following bit of code to /site/ready.php to detect an HTMX request:


$config->htmxRequest = false;
if(array_key_exists('HTTP_HX_REQUEST', $_SERVER)) {
  $config->appendTemplateFile = '';
  $config->htmxRequest = true;
}

We can't use ProcessWire's $config->ajax to detect an HTMX request because ProcessWire detects an AJAX request the traditional way, which is if the "requested with" header is set to XMLHttpRequest. Differentiating between an AJAX request and an HTMX request is useful for more advanced use cases.

Now we need to set up the /htmx-calendar/ endpoint (or whatever name we decide to call that endpoint). ProcessWire can easily handle this with a URL hook that can be added in /site/ready.php:


$wire->addHook('/htmx-calendar/', function(HookEvent $event) {
  return renderCalendarMonthGrid(wire('input')->get->int('year'), wire('input')->get->int('month'));
});

When that endpoint is hit, it will render only the calendar and cease outputting any other content. HTMX will take that output and replace the #calendar div with it.

Make this calendar your own with custom CSS, a small mode for mobile devices or situations where the available width is limited, and injecting in your own events!