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.
-
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.