SubsPlease Time Left Until Release

Time left until release of shows today and tomorrow

// ==UserScript==
// @name         SubsPlease Time Left Until Release
// @version      1.0.7
// @description  Time left until release of shows today and tomorrow
// @author       Hoshiburst
// @match        *://subsplease.org/
// @require      https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js
// @license      GPL-2.0+; http://www.gnu.org/licenses/gpl-2.0.txt
// @namespace    https://greasyfork.org/en/users/91364-hoshiburst
// @run-at       document-idle
// @noframes
// ==/UserScript==

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

/**
 * Sleep until the schedule table is loaded before we replace its contents.
 * Looking at the site's script, `load_schedule` will append a timer once the data is loaded so we can wait for that
 * to exist (instead of waiting until the schedule table has items - since it may be empty if a day has no shows)
 */
const sleepUntilTableLoads = async () => {
  let wcTimeElement = document.querySelector("#current-time .wc_time");
  while(!wcTimeElement) {
    await sleep(1000);
    wcTimeElement = document.querySelector("#current-time .wc_time");
  }
};

/**
 * Fetch the full schedule for a given timezone
 */
const getFullSchedule = async (timeZoneName) => {
  const response = await fetch(`https://subsplease.org/api/?f=schedule&tz=${timeZoneName}`);
  if (response.status !== 200) throw new Error(`Error fetching schedule: response status ${response.status}`);
  return (await response.json()).schedule;
}

const weekday = [
  'Sunday',
  'Monday',
  'Tuesday',
  'Wednesday',
  'Thursday',
  'Friday',
  'Saturday',
];

const zeroPad = (num) => num < 10 ? `0${num}` : `${num}`;

/**
 * Format milliseconds as +/-HH:mm
 */
const formatTimeMs = (ms) => {
  const duration = moment.duration(ms);
  const hoursFloat = Math.abs(duration.asHours());
  const hours = Math.trunc(hoursFloat);
  const minutes = Math.trunc((hoursFloat - hours) * 60);
  const sign = ms >= 0 ? '+' : '-';
  const timeLeft = `${sign}${zeroPad(hours)}:${zeroPad(minutes)}`;
  return timeLeft;
}

const addTimeLeftUntilShowtime = (show, showDate, now) => {
  const showTime = moment(`${showDate} ${show.time}`);
  const timeLeftMs = moment(showTime).diff(now);
  return {
    ...show,
    day: showTime.format('ddd'),
    aired: timeLeftMs < 0,
    timeLeft: formatTimeMs(timeLeftMs),
  }
};

/**
 * Build a html row for each schedule entry
 */
const buildRow = (show) => {
  const tr = document.createElement('tr');
  tr.className = 'schedule-widget-item';

  const showTd = document.createElement('td');
  const timeTd = document.createElement('td');
  showTd.className = 'schedule-widget-show';
  timeTd.className = 'schedule-widget-time';
  tr.appendChild(showTd);
  tr.appendChild(timeTd);

  const showLink = document.createElement('a');
  showLink.title = "Go to show";
  showLink.href = `/shows/${show.page}`;
  showLink.text = show.title;
  showLink.setAttribute('data-preview-image', show.image_url)
  showTd.appendChild(showLink);

  if (show.aired) {
    const timeImg = document.createElement('img');
    timeTd.appendChild(timeImg);
    timeImg.setAttribute('draggable', 'false');
    timeImg.setAttribute('role', 'img');
    timeImg.setAttribute('alt', '✔');
    timeImg.src = 'https://s.w.org/images/core/emoji/13.0.0/svg/2714.svg';
    timeImg.className = 'emoji';
  }
  const padding = show.aired ? ' ' : '';

  const timeTdText = document.createTextNode(`${padding}${show.day} ${show.time} (${show.timeLeft})`);
  timeTd.appendChild(timeTdText);

  return tr;
}

/**
 * Replace the old schedule table
 */
const populateScheduleTable = async (schedule) => {
  await sleepUntilTableLoads();
  const table = document.getElementById('schedule-table');
  table.innerHTML = "";
  const todayRows = schedule.today.map(buildRow);
  const tomorrowRows = schedule.tomorrow.map(buildRow);
  todayRows.forEach(row => table.appendChild(row));
  tomorrowRows.forEach(row => table.appendChild(row));
}

const run = async () => {
  const timeZoneName = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const fullSchedule = await getFullSchedule(timeZoneName);
  const now = moment();
  const todayDate = now.format('YYYY-MM-DD');
  const tomorrowDate = now.clone().add(1, 'day').format('YYYY-MM-DD');
  const todayAndTomorrowSchedule = {
    today: fullSchedule[weekday[now.day()]].map(show => addTimeLeftUntilShowtime(show, todayDate, now)),
    tomorrow: fullSchedule[weekday[(now.day() + 1) % 7]].map(show => addTimeLeftUntilShowtime(show, tomorrowDate, now))
  }
  await populateScheduleTable(todayAndTomorrowSchedule);
}

run();