mmstick / Blockcard TERN Price

// ==UserScript==
// @name         Blockcard TERN Price
// @namespace    https://openuserjs.org/user/mmstick
// @description  Show the value of TERN in the dashboard
// @version      0.4.1
// @author       Michael Aaron Murphy <mmstick@pm.me>
// @match        https://dashboard.getblockcard.com/*
// @grant        none
// @run-at       document-idle
// @copyright    2020, Michael Aaron Murphy
// @license      MIT
// ==/UserScript==

/** TODO:
 * Not yet experienced enough in frontend web dev to know how to asynchronously
 * reload the script whenever the URL changes, since the Blockcard dashboard is
 * a single-page web app, and navigating across pages breaks the script. Any
 * help would be appreciated here.
 */

const DASHBOARD = "https://dashboard.getblockcard.com/dashboard"

/**
 * @param {string} currentLocation
 * @param {number} currentPrice
 * @param {boolean} is_running
 * @param {string} lastUsdBalance
 * @param {null | HTMLElement} ternPriceElement
 * @param {number} usdBalance
 */
class App {
  constructor() {
    this.currentLocation = `${window.location}`
    this.currentPrice = 0
    this.is_running = false
    this.lastUsdBalance = ""
    this.ternPriceElement = null
    this.usdBalance = 0
  }

  /**
   * Injects a custom Tern Price and Tern Price Alert card
   */
  injectTernPriceCard() {
    const baseAssetBlocks = document.getElementsByClassName("base-asset-block")
    if (!baseAssetBlocks || baseAssetBlocks.length < 1) return false;

    let dataAttributes = "";
    for (const attr of baseAssetBlocks[0].attributes) {
      if (attr.name.startsWith("data-v")) {
        dataAttributes += " " + attr.name
      }
    }

    const TERN_PRICE_ID = "tern-price-here"

    let html = `<div id="tern-price" class="divider pl-8 col-md-4 col-12">
            <p class="balance-type">TERN Price</p>
            <p ${dataAttributes} id="${TERN_PRICE_ID}" class="balance" onclick="priceButton()">0</p>
        </div>`;

    baseAssetBlocks[0].insertAdjacentHTML("beforebegin", html)
    this.ternPriceElement = document.getElementById(TERN_PRICE_ID)

    // Now add the price alert element
    html = `<div id="price-alert" class="divider pl-8 col-md-4 col-12">
            <p class="balance-type">TERN Price Alert</p>
            <form style="display:flex; flex-direction:row" onsubmit="return false">
                <div><b style="font-size:1.35rem">$</b></div>
                <input type="text" name="price-above" id="price-above" />
            </form>
            <style>
                #price-above {
                    border: .2rem solid #333;
                    font-weight: 600;
                    margin-left: .5rem
                }
            </style>
        </div>`;

    baseAssetBlocks[0].insertAdjacentHTML("beforebegin", html)

    return true
  }

  /**
   * Fetch the USD and TERN balance cards from the page
   *
   * @returns {[HTMLElement, HTMLElement] | null}
   */
  fetchBalances() {
    const balances = document.getElementsByClassName("balance");
    if (!balances || balances.length < 2) return null
    const [, u, , t] = balances;
    return [u, t]
  }

  /**
   * Check the price-above value and show a notification if the current price is above it.
   */
  priceAlertCheck() {
    const alert = document.getElementById("price-above");
    if (alert) {
      const value = parseFloat(alert.value)
      if (!isNaN(value)) {
        if (value < this.currentPrice) {
          this.priceAlertShow()
        }
      }
    }
  }

  /**
   * Show the price alert desktop notification
   */
  priceAlertShow() {
    const showNotification = () => {
      if (document.visibilityState === "visible") {
        return
      }

      const title = `TERN Value is $${this.currentPrice}`
      const body = `Your balance is $${this.usdBalance.toFixed(2)}`

      const notification = new Notification(title, {
        body
      })

      notification.onclick = () => {
        window.parent.focus()
        notification.close()
      }
    }

    if (Notification.permission === "granted") showNotification()
  }

  /**
   * Adds the price of TERN in the TERN Balance card
   *
   * @param {string} usdb The USD balance
   * @param {string} ternb The TERN balance
   * @returns {string | null}
   */
  ternValueCalculate(usdb, ternb) {
    if (this.lastUsdBalance === usdb) return null;

    this.lastUsdBalance = usdb

    const ntern = ternb.split(' ')[0].replace(/,/g, '');
    const tern = parseFloat(ntern)

    this.usdBalance = parseFloat(usdb.split(' ')[1].replace(/,/g, ''))
    this.currentPrice = (this.usdBalance / tern).toFixed(5)

    return "$" + this.currentPrice;
  }

  /**
   * Fetch the balance elements, parse them, and add the TERN value
   */
  ternValueCheck() {
    const balances = this.fetchBalances()

    if (!balances) return false;

    const [usdb, ternb] = balances;

    this.ternValueUpdate(usdb, ternb)

    return true
  }

  /**
   * Calculate the TERN value and apply it to the TERN Balance card
   *
   * @param {HTMLElement} usdb
   * @param {HTMLElement} ternb
   */
  ternValueUpdate(usdb, ternb) {
    const calculated = this.ternValueCalculate(usdb.innerText, ternb.innerText)

    if (calculated) this.ternPriceElement.innerText = calculated
  }

  /**
   * Start watching the price of Tern
   */
  async start() {
    app.is_running = true

    await doUntil(1000, () => {
      return !this.injectTernPriceCard()
    })

    this.ternValueCheck()

    await doUntil(15000, () => {
      this.ternValueCheck()
      this.priceAlertCheck()
      return true
    })

    app.is_running = false
  }
}

/**
 * Asynchronously sleep for the defined milliseconds before advancing
 *
 * @param {number} ms
 */
async function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

/**
 * Repeatedly call a closure at regular intervals until it returns `false`
 *
 * @param {number} ms
 * @param {() => boolean} func
 */
async function doUntil(ms, func) {
  let cont = true
  while (cont) {
    await sleep(ms)
    cont = func()
  }
}

Notification.requestPermission()

const app = new App()

if (DASHBOARD === app.currentLocation) app.start()