Internet Roadtrip - Vote History

Show the result of the last votes in neal.fun/internet-roadtrip

// ==UserScript==
// @name         Internet Roadtrip - Vote History
// @description  Show the result of the last votes in neal.fun/internet-roadtrip
// @namespace    me.netux.site/user-scripts/internet-roadtrip/vote-history
// @version      2.1.1
// @author       Netux
// @license      MIT
// @match        https://neal.fun/internet-roadtrip/*
// @icon         https://cloudy.netux.site/neal_internet_roadtrip/Vote%20History%20logo.png
// @grant        GM_addStyle
// @grant        GM.getValue
// @grant        GM.setValue
// @run-at       document-start
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// @noframes
// ==/UserScript==

(async () => {
  const CSS_PREFIX = 'vh-';
  const cssClass = (... names) => names.map((name) => `${CSS_PREFIX}${name}`).join(' ');
  const cssProp = (name) => `--${CSS_PREFIX}${name}`;

  const state = {
    dom: {},
    settings: {
      increasedVisibility: false,
      maxEntries: 8,
      debug: {
        honkers: false
      }
    }
  };

  for (const [key, defaultValue] of Object.entries(state.settings)) {
    state.settings[key] = await GM.getValue(key, defaultValue);
  }

  function injectStylesheets() {
    GM_addStyle(`
    .${cssClass('vote-history')} {
      position: fixed;
      left: 10px;
      top: 150px;
      margin: 0;
      padding: 0;
      list-style: none;
      color: white;
      font-family: "Roboto", sans-serif;
      font-size: 0.8rem;
      user-select: none;

      & .${cssClass('vote-history-entry')} {
        margin: 0.5rem 0;
        text-shadow: 1px 1px 2px black;
        display: flex;
        align-items: center;

        .${cssClass('vote-history--increased-visibility')} & {
          text-shadow: ${[[0, 1], [0, -1], [1, 0], [-1, 0]].map(([x, y]) => `${x}px ${y}px 2px rgba(0 0 0 / 50%)`)};
        }

        & .${cssClass('vote-history-entry__icon-container')} {
          position: relative;
          height: 12px;
          aspect-ratio: 1;
          margin-right: 0.5rem;
          vertical-align: middle;

          & > img {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: inline-block;
          }

          & .${cssClass('vote-history-entry__icon-shadow')} {
            opacity: .5;
            scale: 1.5;
            filter: blur(5px);
          }

          & .${cssClass('vote-history-entry__icon')} {
            filter: invert(1);
            z-index: 1;
          }
        }

        & .${cssClass('vote-history-entry__time')} {
          margin-left: 1ch;
          font-size: 80%;
          color: lightgrey;
        }
      }
    }
    `);

    state.dom.voteHistoryEntriesFadeStyleEl = document.createElement('style');
    document.head.appendChild(state.dom.voteHistoryEntriesFadeStyleEl);
  }

  function createSettingsTab() {
    const tab = IRF.ui.panel.createTabFor(
      { ... GM.info, script: { ... GM.info.script, name: GM.info.script.name.replace('Internet Roadtrip - ', '') } },
      {
        tabName: 'Vote History',
        style: `
        .${cssClass('settings-tab-content')} {
          & *, *::before, *::after {
            box-sizing: border-box;
          }

          & .${cssClass('field-group')} {
            margin-block: 1rem;
            gap: 0.25rem;
            display: flex;
            align-items: center;
            justify-content: space-between;

            & input:is(:not([type]), [type="text"], [type="number"]) {
              --padding-inline: 0.5rem;

              width: calc(100% - 2 * var(--padding-inline));
              min-height: 1.5rem;
              margin: 0;
              padding-inline: var(--padding-inline);
              color: white;
              background: transparent;
              border: 1px solid #848e95;
              font-size: 100%;
              border-radius: 5rem;
            }
          }
        }
        `,
        className: cssClass('settings-tab-content')
      }
    );

    function makeFieldGroup({ id, label }, renderInput) {
      const fieldGroupEl = document.createElement('div');
      fieldGroupEl.className = cssClass('field-group');

      const labelEl = document.createElement('label');
      labelEl.textContent = label;
      fieldGroupEl.appendChild(labelEl);

      const inputEl = renderInput({ id });
      fieldGroupEl.appendChild(inputEl);

      return fieldGroupEl;
    }

    tab.container.append(
      makeFieldGroup({ id: `${CSS_PREFIX}max-entries`, label: 'Past Votes Max Entries' }, ({ id }) => {
        const inputEl = document.createElement('input');
        inputEl.id = id;
        inputEl.type = 'number';
        inputEl.style.width = '10ch';
        inputEl.value = state.settings.maxEntries;

        inputEl.addEventListener('change', async () => {
          const numberValue = parseInt(inputEl.value);
          if (Number.isNaN(numberValue)) {
            return;
          }

          state.settings.maxEntries = numberValue;

          await saveSettings();
          updateDomFromSettings();
        });

        return inputEl;
      }),
      makeFieldGroup({ id: `${CSS_PREFIX}increase-visiblity`, label: 'Increase Visibility' }, ({ id }) => {
        const inputEl = document.createElement('input');
        inputEl.id = id;
        inputEl.type = 'checkbox';
        inputEl.className = IRF.ui.panel.styles.toggle;
        inputEl.checked = state.settings.increasedVisibility;

        inputEl.addEventListener('change', async () => {
          state.settings.increasedVisibility = inputEl.checked;
          await saveSettings();
          updateDomFromSettings();
        });

        return inputEl;
      }),
    );
  }

  async function setupDom() {
    await IRF.vdom.container; // FIX(netux): required to avoid crashing other userscripts :s

    const containerEl = await IRF.dom.container;

    injectStylesheets();
    createSettingsTab();

    state.dom.voteHistoryContainerEl = document.createElement('ul');
    state.dom.voteHistoryContainerEl.className = cssClass('vote-history');
    containerEl.appendChild(state.dom.voteHistoryContainerEl);

    updateDomFromSettings();
  }

  async function patch() {
    const containerVDOM = await IRF.vdom.container;

    containerVDOM.state.changeStop = new Proxy(containerVDOM.methods.changeStop, {
      apply(ogChangeStop, thisArg, args) {
        const currentChosen = args[1];

        if (!thisArg.isChangingStop) {
          const payload = { currentChosen, currentHeading: thisArg.currentHeading, currentOptions: thisArg.currentOptions, voteCounts: thisArg.voteCounts };

          addVote(payload).catch((error) => {
            console.error('Could not add vote to history:', payload, error);
          })
        }

        return ogChangeStop.apply(thisArg, args);
      }
    });
  }

  async function saveSettings() {
    for (const [key, value] of Object.entries(state.settings)) {
      await GM.setValue(key, value);
    }
  }

  function updateDomFromSettings() {
    removeExcessVotes();

    // TODO(netux): it'd be nice to be able to use CSS variables and counters here,
    // but counter() does not work inside calc() :(
    // See https://github.com/w3c/csswg-drafts/issues/1026
    state.dom.voteHistoryEntriesFadeStyleEl.textContent = `
    .${cssClass('vote-history')} {
      ${new Array(state.settings.maxEntries).fill(null).map((_, i) => `
      .${cssClass('vote-history-entry')}:nth-child(${i + 1}) {
        opacity: ${1.2 - Math.pow(1 - ((state.settings.maxEntries - i) / state.settings.maxEntries), 2)};
      }
      `).join('\n')}
    }
    `;

    state.dom.voteHistoryContainerEl.classList.toggle(cssClass('vote-history--increased-visibility'), state.settings.increasedVisibility);
  }

  function removeExcessVotes() {
    while (state.dom.voteHistoryContainerEl.childElementCount > Math.max(0, state.settings.maxEntries)) {
      state.dom.voteHistoryContainerEl.lastElementChild.remove();
    }
  }

  async function addVote({ currentChosen: vote, currentHeading: heading, currentOptions: options, voteCounts }) {
    const resultsVDOM = await IRF.vdom.results;

    const newEntryEl = document.createElement('li');
    newEntryEl.className = cssClass('vote-history-entry');

    let entryActionText = '?';
    let entryIconSrc = null;
    let entryIconRotation = 0;
    switch (vote) {
      case -2: {
        entryActionText = 'HONK!';
        entryIconSrc = '/internet-roadtrip/honk.svg'
        break;
      }
      case -1: {
        entryActionText = 'Seek Radio';
        entryIconSrc = '/internet-roadtrip/skip.svg'
        break;
      }
      default: {
        entryIconSrc = '/internet-roadtrip/chevron-black.svg';
        if (options[vote]) {
          entryActionText = options[vote].description;
          entryIconRotation = resultsVDOM.methods.getRotation(vote);
        } else {
          entryActionText = 'U-Turn';
          entryIconRotation = 180;
        }
        break;
      }
    }

    const voteIconEl = document.createElement('div');
    voteIconEl.className = cssClass('vote-history-entry__icon-container');
    if (entryIconRotation !== 0) {
      voteIconEl.style.rotate = `${entryIconRotation}deg`;
    }
    newEntryEl.appendChild(voteIconEl);

    const voteIconShadowImageEl = document.createElement('img');
    voteIconShadowImageEl.className = cssClass('vote-history-entry__icon-shadow');
    voteIconShadowImageEl.src = entryIconSrc;
    voteIconEl.appendChild(voteIconShadowImageEl);

    const voteIconImageEl = document.createElement('img');
    voteIconImageEl.className = cssClass('vote-history-entry__icon');
    voteIconImageEl.src = entryIconSrc;
    voteIconEl.appendChild(voteIconImageEl);

    const voteCount = voteCounts[vote];
    const entryVotesText = voteCount != null
      ? `${voteCount} vote${voteCount === 1 ? '' : 's'}, ${Math.round(voteCount / Object.values(voteCounts).reduce((acc, votes) => acc + votes, 0) * 100)}%`
      : 'no votes';
    const entryTextNode = document.createTextNode(`${entryActionText} (${entryVotesText})`);
    newEntryEl.appendChild(entryTextNode);

    const entryTimeEl = document.createElement('span');
    entryTimeEl.className = cssClass('vote-history-entry__time');
    entryTimeEl.innerText = new Date().toLocaleTimeString();
    newEntryEl.appendChild(entryTimeEl);

    if (state.dom.voteHistoryContainerEl.childElementCount > 0) {
      state.dom.voteHistoryContainerEl.insertBefore(newEntryEl, state.dom.voteHistoryContainerEl.firstChild);
    } else {
      state.dom.voteHistoryContainerEl.appendChild(newEntryEl);
    }

    removeExcessVotes();
  }

  if (typeof unsafeWindow !== 'undefined') {
    unsafeWindow.DEBUG__addVoteToHistory = addVote;
  }

  await Promise.all([
    setupDom(),
    patch()
  ])
    .then(async () => {
      if (state.settings.debug?.honkers) {
        for (let i = 0; i < state.settings.maxEntries; i++) {
          const vote = -2;
          await addVote({
            currentChosen: vote,
            currentHeading: 0,
            currentOptions: { [vote]: { heading: 180 } },
            voteCounts: { [vote]: 100, [9999]: 50 }
          });
        }
      }
    });
})();