dperelman / Folklife 2024 schedule fixes

// ==UserScript==
// @name         Folklife 2024 schedule fixes
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  Show schedule as a grid.
// @author       Daniel Perelman (perelman@aweirdimagination.net)
// @match        https://app.nwfolklife.org/embeddable/events/*/schedule
// @icon         https://www.google.com/s2/favicons?sz=64&domain=nwfolklife.org
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  const dayOfWeek = new Intl.DateTimeFormat("en-US", { weekday: "long" })

  let data = null;

  const w = window.wrappedJSObject || window;

  const ourSetData = json => {
    if (!data) data = [];
    const newData = JSON.parse(json).data;
    const newBlocks = newData.blocks;
    if (newBlocks) data.push(...newBlocks);
    const newVenues = newData.venues;
    if (newVenues) {
      // To avoid rewriting the code below, just rewrite into list of blocks.
      for (const venue of newVenues) {
        const name = venue.name
        const blocks = venue.blocks
        for (const b of blocks) {
          b.venue = { name }
        }
        data.push(...blocks)
      }
    }
  }

  if (window.wrappedJSObject) {


    exportFunction(ourSetData, window, { defineAs: "shareDataWithFixes" })
    w.eval("window.originalFetch = window.fetch")

    // From https://stackoverflow.com/a/69521684
    w.eval(`window.fetch = ${async (...args) => {
      let [resource, config ] = args;
      // request interceptor here
      const response = await window.originalFetch(resource, config);
      // response interceptor here
      window.shareDataWithFixes(await response.clone().text())
      return response;
    }}`);
  } else {
    // From https://blog.logrocket.com/intercepting-javascript-fetch-api-requests-responses/
    const { fetch: originalFetch } = window;

    w.fetch = async (...args) => {
      let [resource, config ] = args;
      // request interceptor here
      const response = await originalFetch(resource, config);
      // response interceptor here
      if (!data) data = [];
      ourSetData(await response.clone().text());
      return response;
    };
  }

  let gridParent = null
  let existingGrid = null
  let newContainer = null
  let currentTableDay = null
  const buildTable = {}

  document.body.style.overflow = "scroll"

  let categories = null
  function setupCategories() {
    if (categories) return
    const categoriesDiv = document.querySelector(".categories")
    if (!categoriesDiv) return

    categories = {}
    for (const cat of categoriesDiv.querySelectorAll(".cat")) {
      const name = cat.classList[0] == "cat" ? cat.classList[1] : cat.classList[0];
      const catInfo = categories[name] = {
        div: cat,
        name,
        visible: true,
      }
      cat.addEventListener('click', () => {
        catInfo.visible = !catInfo.visible
        cat.style["text-decoration"] = catInfo.visible ? "" : "line-through"
        const newDisplay = catInfo.visible ? "" : "none";
        for (const div of document.querySelectorAll("." + name)) {
          if (div.classList.contains("block")) div.style.display = newDisplay
        }
      });
    }
  }

  function updateTable() {
    const daySpan = document.querySelector(".rs-picker-toggle-value");
    if (!daySpan) return
    const day = daySpan.innerText;
    if (buildTable[day]) {
      buildTable[day]()
      return
    }

    for (const t of document.getElementsByTagName("table")) {
      t.parentNode.removeChild(t)
    }

    if (newContainer) {
      const loadingTable = document.createElement("table")
      loadingTable.innerText = "Loading..."
      newContainer.appendChild(loadingTable)
    }

    existingGrid = document.querySelector("div.venues-grid.schedule")
    gridParent = existingGrid.parentNode
    const blocks = existingGrid.querySelectorAll(".block")
    const venues = [...existingGrid.querySelectorAll("p.venue-name")]
    if (!venues || venues.length == 0) {
      console.log("No data displayed for " + day)
      return
    }

    if (!newContainer) {
      let node = existingGrid.parentNode
      let prevNode = null
      while (node != document.body) {
        const newNode = document.createElement("div")
        if (!newContainer) newContainer = newNode
        else newNode.appendChild(prevNode)
        newNode.className = node.className

        if (node.parentNode == document.body) node.appendChild(prevNode)
        prevNode = newNode
        node = node.parentNode
      }
    }

    const blocksByTitle = Object.fromEntries([...blocks].map(b => [b.querySelector(".title").innerText.trim(), b]))

    if (!data) {
      data = Object.values(w.__NEXT_DATA__.props.pageProps.urqlState)
        .flatMap(v => JSON.parse(v.data).blocks || [])
    }

    for (const entry of data) {
      entry.startsAt = new Date(entry.startsAt)
      entry.endsAt = new Date(entry.endsAt)
    }


    const dataForDay = data.filter(x => dayOfWeek.format(x.startsAt) == day)
    if (dataForDay.length == 0) {
      console.log("No data loaded for " + day)
      return
    }

    const dayStartsAt = new Date(Math.min.apply(null, dataForDay.map(x => x.startsAt)))
    const dayEndsAt = new Date(Math.max.apply(null, dataForDay.map(x => x.endsAt)))

    const fiveMinSegments = (dayEndsAt - dayStartsAt) / 1000 / 60 / 5

    for (const entry of dataForDay) {
      entry.rowStart = (entry.startsAt - dayStartsAt) / 1000 / 60 / 5
      entry.rowEnd = -1 + (entry.endsAt - dayStartsAt) / 1000 / 60 / 5
    }

    buildTable[day] = function() {
      if (day == currentTableDay) return;
      let error = false
      for (const t of document.getElementsByTagName("table")) {
        t.parentNode.removeChild(t)
      }
      setupCategories()

      const newGrid = document.createElement("table")
      const columns = []

      const headerTR = document.createElement("tr")
      for (const venueP of venues) {
        const venueName = venueP.innerText
        const th = document.createElement("th")
        th.appendChild(venueP.cloneNode(true))
        headerTR.appendChild(th)

        const dataForVenue = dataForDay.filter(x => x.venue.name == venueName)
        const columnEntries = new Array(fiveMinSegments)
        for (let i = 0; i < fiveMinSegments; i++) {
          columnEntries[i] = dataForVenue.find(x => x.rowStart <= i && i <= x.rowEnd)
        }
        columns.push(columnEntries)
      }
      newGrid.appendChild(headerTR)
      const trClass = existingGrid.querySelector(".blocks").className

      for (let i = 0; i < fiveMinSegments; i++) {
        const tr = document.createElement('tr')
        tr.className = trClass
        tr.style.display = "table-row"

        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.className = "jJAqXz"
            td.style.display = "table-cell"
            td.rowSpan = cell.rowEnd - cell.rowStart + 1
            const block = blocksByTitle[cell.title.trim()]
            if (!block) {
              console.log("No block with title: " + cell.title)
              error = true
              buildTable[day] = null
            }
            else {
              const blockClone = block.cloneNode(true)
              if (categories) {
                blockClone.classList.forEach(cls => {
                  if (categories[cls] && !categories[cls].visible) {
                    blockClone.style.display = "none"
                  }
                })
              }
              td.appendChild(blockClone)
            }
            tr.appendChild(td)
          }
        }

        newGrid.appendChild(tr)
      }

      newGrid.style.display = "table"
      newGrid.className = existingGrid.className
      document.getElementById("__next").childNodes[0].style.height = document.querySelectorAll(".layout-row")[0].getBoundingClientRect().bottom + "px"
      existingGrid.style.display = "none"

      newContainer.appendChild(newGrid)
      currentTableDay = error ? null : day
    }
    buildTable[day]()
  }
  setInterval(updateTable, 500);
})();