A Weird Imagination

Devlog: Folklife schedule user script (1 of 2): building the grid

Posted in

The problem#

The Folklife 2024 schedule page1 is a schedule grid: locations are along the x-axis and time is along the y-axis. Except it's not actually arranged as a grid: each column is just stacked in order with no correspondence to the other columns or the absolute times of the events. Glancing at the code, I noticed the schedule data was available in JSON format, so it should be pretty easy write a user script to display the schedule in a slightly different format.

But when I went to actually make the changes, I found the code is obfuscated React that turned out to be tricky to modify.

The solution#

I was able to write this user script (git repo), which changes the display from the columns of events to a schedule grid. It even works on Firefox mobile, although only if you explicitly request the desktop site.

The details#

Getting the data#

At first I thought getting the schedule data would be straightforward: there was a window.__NEXT_DATA__ variable that appeared promising at first glance. But upon investigation, I noticed it only had the data for the first day (out of four) of the schedule. When the user selects another day from the drop-down, it requests that day's schedule data from the server. It actually seems to also request the first day's data even though that appears in the __NEXT_DATA__ variable.

That's what led me down the rabbit hole of figuring out how to monkey patch fetch() so I could get access to the schedule data it requests. The end result was that I was able to capture the result whenever the page requested a new day's schedule data and record the answer in a variable local to my script so my script would have access to it.

Getting the schedule blocks#

In order to minimize how much I was modifying the page, I wanted to reuse the <div>s in the existing schedule and just move them around. Luckily, the elements did have human-readable class names, so I could just grab all of the elements of class block. To identify them, I used their title which was easy to extract as it appeared in an element of class title. Strangely, the whitespace wasn't entirely consistent between the titles in the HTML and the titles in JSON, but it turned out to be sufficient to normalize both with String.trim().

In code, that looks like:

const existingGrid = document.querySelector(
    "div.venues-grid.schedule")
const blocks = existingGrid.querySelectorAll(".block")
const blocksByTitle = Object.fromEntries([...blocks]
    .map(b => [b.querySelector(".title").innerText.trim(),
               b]))
// ... use later in loop over the relevant subset of data:
for (const entry of dataSubset) {
    const blockForEntry = blocksByTitle[entry.title.trim()]
    // ...
}

Building the grid#

I've built software that uses Javascript to render a schedule grid with HTML tables in the past: Schedule Grid Editor (source), so I already knew the basic algorithm. The general idea is to layout a <table> where each event is a <td> with a rowSpan corresponding to its duration with some bookkeeping to figure out what empty cells are needed where to get the events into the right positions. The first step is to determine the start and end times and the size of the time slices (that is, how much time each row of the table corresponds to). Then you can compute the index of the row each event should start and end at:

const dayStart = new Date(Math.min.apply(null,
                        dataForDay.map(x => x.startsAt)));
const dayEnds = new Date(Math.max.apply(null,
                        dataForDay.map(x => x.endsAt)));
const sliceMs = 1000 * 60 * 5; // 5 minutes
const fiveMinSegments = (dayEnd - dayStart) / sliceMs;

for (const entry of dataForDay) {
  entry.rowStart = (entry.start - dayStart) / sliceMs;
  entry.rowEnd = -1 + (entry.end - dayStart) / sliceMs;
}

At first, I tried to use that information to apply styles to reposition the elements, but I couldn't come up with a way to do so that would not involve setting explicit pixel positions. Although you can use display: table and related properties to make <div>s get treated like <table>/<tr>/<td> tags, there's no way to apply a rowSpan to a <div>; you have to use an actual <td> or fake it in some way that involves being more explicit about the positioning.

Instead, I replaced the <div> with a fresh <table>, moving the block <div>s into <td>s that I created. To actually get them into the right position, I built a two-dimensional array of which cell I expected at each position in the table and then iterated over it to actually build the table. That prep is necessary because with rowSpan (and colSpan), instead of having an equal number of <td> in each row, the browser automatically skips over the cells that are covered by a cell in the row(s) above with a rowSpan including the current row, so you need to keep track of which cells to skip. The basic logic is

for (let i = 0; i < fiveMinSegments; i++) {
  const tr = document.createElement('tr');

  for (const col of columns) {
    const cell = col[i];
    if (!cell) {
      tr.appendChild(document.createElement('td');
    } else if (cell.rowStart == i) {
      const td = document.createElement('td')
      td.rowSpan = cell.rowEnd - cell.rowStart + 1
      td.appendChild(blocksByTitle[cell.title.trim()])
      tr.appendChild(td);
    } else {
      // Do nothing because this overlaps a rowSpan.
    }
  }

  table.appendChild(tr);
}

Invoking the script#

At this point, I had a v0.1 of the script that was functional, but barely: in order to delay the decision of when to run the script, I originally just added a button to the top of the page to invoke it. But, also, in order to use the script, I had to be careful about how I interacted with the page. Changing the day to display after running the script just made the page error out, so viewing a different day's schedule required reloading the page.

I encountered multiple issues getting the script to run automatically and allow switching days without reloading the page, which I will cover next week.


  1. This event ended before this post was published, so this link may be broken. I published my previous post about this script before the event, so I would have some blog post mentioning it before it was no longer useful. 

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.