WaniKani Coming Up

Shows upcoming progression reviews concisely

// ==UserScript==
// @name         WaniKani Coming Up
// @namespace    wk_lai
// @version      1.11
// @description  Shows upcoming progression reviews concisely
// @author       lai
// @match        *://www.wanikani.com/*
// @match        *://preview.wanikani.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

function RunInPage(func) {
  var s = document.createElement("script");
  s.textContent = "(" + func + ")();";
  document.body.appendChild(s);
  setTimeout(function(){document.body.removeChild(s)}, 0);
}

function bootstrap() {
  const console = /preview\./.test(window.location.href) ? window.console : new Proxy({}, {get: () => () => {}});

// Defined by WaniKani
  const unlockedAtKey = "unlockedAt";
  const availableAtKey = "availableAt";
  const passedAtKey = "passedAt";
  const srsStageKey = "srsStage";

// Defined locally
  const valuesKey = "values";
  const availableAtMsKey = "availableAtMs";
  const amountKey = "amount";
  const srsKey = "srs";
  const tagsKey = "tags";
  const passedKey = "passed";
  const allPassedKey = "allPassed";

  const srsScoresByStage = [0, 14400000, 43200000, 126000000, 295200000, 896400000, 2102400000, 4690800000, 15055200000];
  const timescaleSetHours = [6, 12, 24, 48, 72, 96, 168, 336];

  const groupBy = function (xs, key) {
    const getOrSetEmpty = (map, key) => {
      const result = map.get(key);
      if (result) return result;
      const newValue = [];
      map.set(key, newValue);
      return newValue
    };
    return xs.reduce((map, x) => {
      const group = getOrSetEmpty(map, x[key]);
      group.push(x);
      return map;
    }, new Map());
  };

  const first = (arr) => arr[0];
  const remove = (arr, obj) => {
    for (let i = 0; i < arr; i++) {
      if (arr[i] === obj) {
        arr.splice(i, 1);
        return true;
      }
    }
    return false;
  };
  const orderBy = (arr, predicate) => arr.slice().sort(predicate);

  const Time = {
    getTime: (dateTime) => new Date(dateTime).getTime(),
    hoursToMs: (h) => h * 1000 * 60 * 60,
    getFriendlyTime: (ms) => {
      const minutes = Math.ceil(ms / 1000 / 60);
      if (minutes <= 0) {
        return {value: 'now'};
      }
      if (minutes <= 60) {
        return {unit: 'minutes', value: minutes};
      }
      const hours = Math.ceil(minutes / 60);
      if (hours <= 48) {
        return {unit: 'hours', value: hours};
      }
      const days = Math.ceil(hours / 24);
      return {unit: 'days', value: days};
    },
    getFriendlyTimeLiteral(ms) {
      const time = Time.getFriendlyTime(ms);
      switch (time.unit) {
        case 'minutes':
          const suffix = time.value === 1 ? 'minute' : 'minutes';
          return `${time.value} ${suffix}`;
        case 'hours':
          return `${time.value} hours`;
        case 'days':
          return `${time.value} days`;
        default:
          return time.value;
      }
    }
  };


  class PubSub {
    constructor() {
      this.events = {};
    }

    subscribe(event, callback) {
      const self = this;

      if (!self.events.hasOwnProperty(event)) {
        self.events[event] = [];
      }

      return self.events[event].push(callback);
    }

    unsubscribe(event, callback) {
      const self = this;

      if (!self.events.hasOwnProperty(event)) {
        return false;
      }

      return remove(self.events[event], callback);

    }

    publish(event, data = {}) {
      const self = this;

      if (!self.events.hasOwnProperty(event)) {
        return [];
      }

      return self.events[event].map(callback => callback(data));
    }
  }

  class Store {
    constructor(params) {
      const self = this;
      self.actions = params.actions || {};
      self.mutations = params.mutations || {};
      self.state = {};
      self.status = 'resting';

      self.events = new PubSub();

      const handler = {
        get(target, key) {
          if (typeof target[key] === 'object' && target[key] !== null) {
            return new Proxy(target[key], handler)
          } else {
            return target[key];
          }
        },
        set: function (state, key, value) {
          state[key] = value;

          console.log(`stateChange: ${key}: ${value}`);

          self.events.publish('stateChange', self.state);

          if (self.status !== 'mutation') {
            console.warn(`${key} was not set via mutation.`);
          }

          self.status = 'resting';

          return true;
        }
      };
      self.state = new Proxy((params.state || {}), handler);
    }

    dispatch(actionKey, payload) {

      const self = this;

      if (typeof self.actions[actionKey] !== 'function') {
        console.warn(`Action "${actionKey}" doesn't exist.`);
        return false;
      }

      console.groupCollapsed(`ACTION: ${actionKey}`);

      self.status = 'action';

      self.actions[actionKey](self, payload);

      console.groupEnd();

      return true;
    }

    commit(mutationKey, payload) {
      const self = this;

      if (typeof self.mutations[mutationKey] !== 'function') {
        console.warn(`Mutation "${mutationKey}" doesn't exist`);
        return false;
      }

      self.status = 'mutation';

      const newState = self.mutations[mutationKey](self.state, payload);

      self.state = Object.assign(self.state, newState);

      return true;
    }
  }

  class Component extends HTMLElement {
    constructor(props = {}) {
      super();
      const self = this;

      self.$element = $(self);

      if (props.store instanceof Store) {
        props.store.events.subscribe('stateChange', () => self._render());
      }
    }

    connectedCallback() {
      this._render();
      this.componentDidMount();
    }

    disconnectedCallback() {
      this.componentDidDismount();
    }

    render() {
    };

    componentDidMount() {
    };

    componentDidDismount() {
    };

    _render() {
      $(this).html(this.render());
    }
  }

  const cuSettings = {
    load() {
      const storedSettings = JSON.parse(localStorage.getItem("cu-settings"));
      const defaultSettings = {maxGroups: 4, showTimeline: true, showPassedItems: false, indicatorStyle: "cone", staticTimescaleMs: Time.hoursToMs(48)};
      return Object.assign(defaultSettings, storedSettings);
    },
    save(settings) {
      const settingsJson = JSON.stringify(settings);
      localStorage.setItem("cu-settings", settingsJson);
    }
  };

  const actions = {
    setMaxGroups(context, payload) {
      context.commit("setMaxGroups", payload);
      context.dispatch("storeSettings");
      context.dispatch("refreshDomainModel");
      context.dispatch("updateUi");
    },
    setStaticTimescale(context, payload) {
      context.commit("setStaticTimescale", payload);
      context.dispatch("storeSettings");
      context.dispatch("updateUi");
    },
    setShowTimeline(context, payload) {
      context.commit("setShowTimeline", payload);
      context.dispatch("storeSettings");
      context.dispatch("updateUi");
    },
    setShowPassedItems(context, payload) {
      context.commit("setShowPassedItems", payload);
      context.dispatch("storeSettings");
      context.dispatch("refreshDomainModel");
      context.dispatch("updateUi");
    },
    setIndicatorStyle(context, payload) {
      context.commit("setIndicatorStyle", payload);
      context.dispatch("storeSettings");
      context.dispatch("refreshDomainModel");
      context.dispatch("updateUi");
    },
    showOverflow(context, payload) {
      context.commit("setShowOverflow", payload);
      context.dispatch("updateUi");
    },
    storeSettings(context) {
      cuSettings.save(context.state.settings);
    },
    refreshTimestamp(context) {
      const now = new Date().getTime();
      context.commit("setTimestamp", now);
    },
    updateUi(context) {
      context.dispatch("refreshTimestamp");
      context.dispatch("refreshTimescale");
      context.events.publish("refresh-ui");
    },
    refreshTimescale(context) {
      function fitToGroups(groups) {
        const availableIn = groups.map(v => v[availableAtMsKey] - context.state.timestamp);
        return availableIn.reduce((prev, cur) => context.state.timescaleSet.find(x => x > cur) || prev, 0);
      }
      function calculateTimescale() {
        switch (context.state.settings.showTimeline) {
          case "hidden":
            return null;
          case "fitGroups":
            const relevantGroups = context.state.domainModel.slice(0, context.state.settings.maxGroups);
            return fitToGroups(relevantGroups);
          case "fitAll":
            return fitToGroups(context.state.domainModel);
          case "static":
            return context.state.settings.staticTimescaleMs;
        }
      }
      const timescale = calculateTimescale();
      context.commit("setTimescale", timescale);
    },
    refreshDomainModel(context) {
      const unlockedItems = context.state.levelData.filter(v => v[unlockedAtKey]);
      const notYetPassedItems = filterPassedItems(unlockedItems);
      const groupedData = Array.from(groupBy(notYetPassedItems, availableAtKey));
      const filteredData = groupedData.filter(x => x[0] != null).filter(x => x[1].length);
      if (!filteredData.length){
        return context.commit("setDomainModel", []);
      }
      const domainModel = filteredData.map(d => createDomainModel(d));
      const sorted = tagGroupsAndSort(domainModel);
      context.commit("setDomainModel", sorted);

      function filterPassedItems(items) {
        if (context.state.settings.showPassedItems) {
          return items;
        }
        return items.filter(i => !i[passedAtKey]);
      }

      function calculateSrsData(availableAt, values) {
        const srs = values.map(v => {
          const stage = v[srsStageKey];
          const score = srsScoresByStage[stage] - availableAt;
          return { stage, score };
        });
        const lowOrdered = orderBy(srs, (a, b) => a.score - b.score);
        const lowestSrs = first(lowOrdered);
        const isStageHomogeneous = srs.every(s => s.stage === lowestSrs.stage);
        return { ...lowestSrs, isStageHomogeneous };
      }

      function sortByLowestLevel(groups) {
        return orderBy(groups, (a, b) => a[srsKey].score - b[srsKey].score);
      }

      function sortByLargestAmount(groups) {
        return orderBy(groups, (a, b) => b[amountKey] - a[amountKey]);
      }

      function sortByFirstAvailable(groups) {
        return orderBy(groups, (a, b) => a[availableAtMsKey] - b[availableAtMsKey]);
      }

      function createDomainModel(x) {
        const values = x[1];
        const allPassed = values.every(x => x[passedKey]);
        const availableAt = Time.getTime(x[0]);
        const srsData = calculateSrsData(availableAt, values);

        return {
          [valuesKey]: values,
          [availableAtMsKey]: availableAt,
          [amountKey]: values.length,
          [srsKey]: srsData,
          [allPassedKey]: allPassed,
          [tagsKey]: []
        };
      }

      function tagGroupsAndSort(groups) {
        const lowestGroup = first(sortByLowestLevel(groups));
        const largestGroup = first(sortByLargestAmount(groups));
        const groupsSortedByEarliest = sortByFirstAvailable(groups);
        const earliestGroup = first(groupsSortedByEarliest);

        earliestGroup.isEarliest = true;
        largestGroup.isLargest = true;
        lowestGroup.isLowest = true;

        return groupsSortedByEarliest;
      }
    }
  };

  const mutations = {
    setMaxGroups(state, payload) {
      state.settings.maxGroups = payload;
    },
    setShowTimeline(state, payload) {
      state.settings.showTimeline = payload;
    },
    setStaticTimescale(state, payload) {
      state.settings.staticTimescaleMs = payload;
    },
    setShowPassedItems(state, payload) {
      state.settings.showPassedItems = payload;
    },
    setIndicatorStyle(state, payload) {
      state.settings.indicatorStyle = payload;
    },
    setDomainModel(state, payload) {
      state.domainModel = payload;
    },
    setTimestamp(state, payload) {
      state.timestamp = payload;
    },
    setTimescale(state, payload) {
      state.timescale = payload;
    },
    setShowOverflow(state, {index, flag}) {
      state.showOverflow[index] = flag;
    },
  };

  const levelData = $("[data-react-class='Progress/Progress']").data("react-props").data;
  const store = new Store({
    actions,
    mutations,
    state: {
      levelData,
      settings: cuSettings.load(),
      timescaleSet: timescaleSetHours.map(Time.hoursToMs),
      showOverflow: {}
    }
  });
  store.dispatch("refreshDomainModel");


  class GroupGridComponent extends Component {
    constructor() {
      super({
        store
      });

      this.refresh = this.refresh.bind(this);
      this.resizeUpdate = this.resizeUpdate.bind(this);
    }

    componentDidMount() {
      store.events.subscribe("refresh-ui", this.refresh);
      store.events.subscribe("resizeUpdate", this.resizeUpdate);
      this.refresh();
    }

    componentDidDismount() {
      store.events.unsubscribe("refresh-ui", this.refresh);
      store.events.unsubscribe("resizeUpdate", this.resizeUpdate);
    }

    refresh() {
      this.updateGroupTimes();
      this.resizeUpdate(); // updating group times might have resized the grid ...
    }

    render() {
      return store.state.domainModel
        .slice(0, store.state.settings.maxGroups)
        .map((g, i) => this.renderGroup(g, i));
    }

    renderGroup(group, index) {
      const $head = this.renderGroupHead(group, index);
      const $body = this.renderBody(group, index);

      return $("<div class='group' />")
        .append($head)
        .append($body);
    };

    getShowOverflowData(index){
      if (store.state.showOverflow[index]) {
        const $collapseButton = $("<div class='value btn icon overflow-icon icon-close' />");
        $collapseButton.on('click', () => store.dispatch("showOverflow", {index, flag: false}));
        return {
          className: "show-overflow",
          $element: $collapseButton
        };
      }
      return { className: null, $element: null };
    }

    renderBody(group, index) {
      const gridValues = group[valuesKey].map(v => this.renderGridValue(v));
      const { className, $element } = this.getShowOverflowData(index);
console.log(className, $element);
      return $("<div class='grid' />")
        .addClass(className)
        .append(gridValues)
        .append($element);
    };

    renderTag(tagContent, title) {
      return $("<div class='tag' />")
        .append(tagContent)
        .attr("title", title);
    }

    renderSrsStageTag(group) {
      const text = ["Stage"];
      const title = ["The SRS stage of this group."];
      const srsData = group[srsKey];
      text.push(srsData.stage);
      if (!srsData.isStageHomogeneous) {
        text.push("+");
        title.push("A '+' indicates at least one item of a higher stage.")
      }
      if (group[allPassedKey]) {
        title.push("You have passed all items in this group.");
      } else {
        title.push("Upon reaching stage 5 items are guru'ed and therefore passed.");
      }
      return this.renderTag(document.createTextNode(text.join(' ')), title.join(' '));
    }
    renderCriticalTag(group) {
      if (!group.isLowest) {
        return;
      }
      const content = $("<i class='icon icon-bolt' />");
      return this.renderTag(content, "This group contains items with the lowest SRS Score. The SRS Score is calculated by SRS Stage and time until the review is available.");
    }
    renderTags(group) {
      const srsStageTag = this.renderSrsStageTag(group);
      const criticalTag = this.renderCriticalTag(group);
      return [srsStageTag, criticalTag];
    };

    renderTimeToGo(group) {
      return $("<div class='group-time' />")
        .data(availableAtMsKey, group[availableAtMsKey])
    };

    renderGridItemContent(value) {
      if (value.characterImage) {
        return $("<img src='' class='character-image'/>")
          .attr("src", value.characterImage);
      } else {
        return $(document.createTextNode(value.characters));
      }
    }

    renderGridValue(value) {
      const className = this.getClassName(value.type);
      const $itemContent = this.renderGridItemContent(value);

      return $("<div class='value' />")
        .addClass(className)
        .append($itemContent);
    };

    renderGroupNumber(group, index) {
      return $("<div class='group-number' />")
        .attr("all-passed", group[allPassedKey])
        .text(index + 1);
    }

    renderGroupHead(group, index) {
      const tags = this.renderTags(group);
      const $number = this.renderGroupNumber(group, index);
      const $spacer = $("<div class='spacer' />");
      const $timeToGo = this.renderTimeToGo(group);

      return $("<div class='group-head' />")
        .append($number)
        .append(tags)
        .append($spacer)
        .append($timeToGo);
    }

    getClassName(type) {
      switch (type) {
        case "Kanji":
          return "kanji-icon";
        case "Radical":
          return "radical-icon";
      }
    };

    updateGroupTimes() {
      this.$element.find(".group-time").each((i, e) => {
        const $groupTime = $(e);
        const availableAt = $groupTime.data(availableAtMsKey);
        const availableIn = (availableAt - store.state.timestamp);
        const text = this.getGroupTimeText(availableIn);
        $groupTime.text(text);
      });
    }

    resizeUpdate() {
      this.$element.find(".group").each((i, e) => {
        if (store.state.showOverflow[i]) return;

        const $group = $(e);
        $group.find(".overflow-icon").remove();
        const $grid = $group.find(".grid");
        const $values = $group.find(".value");
        const bounds = {x: $grid.width(), y: $grid.height()};

        const $visibles = $values.filter((i, v) => {
          return v.offsetLeft < bounds.x && v.offsetTop < bounds.y;
        });
        if ($visibles.length === $values.length) return;

        const lastVisible = $visibles.last();
        const numInvisible = $values.length - $visibles.length + 1; // +1 because the last visible element now gets obscured.

        const $overflowIndicator = this.renderOverflowIndicator(i, numInvisible);

        $(lastVisible).append($overflowIndicator);
      });
    };
    renderOverflowIndicator(index, numInvisible){
      return $("<div class='btn overflow-icon' />")
        .text(`+${numInvisible}`)
        .on("click", () => store.dispatch("showOverflow", {index, flag: true}))
    }

    getGroupTimeText(availableIn) {
      if (availableIn <= 0) {
        return "available now";
      } else {
        const text = Time.getFriendlyTimeLiteral(availableIn);
        return `in ${text}`;
      }
    }
  }

  class TimelineComponent extends Component {
    constructor() {
      super({
        store
      });
      this.refresh = this.refresh.bind(this);
    }

    componentDidMount() {
      store.events.subscribe("refresh-ui", this.refresh);
      this.refresh();
    }

    componentDidDismount() {
      store.events.unsubscribe("refresh-ui", this.refresh);
    }

    refresh() {
      this.updateTimelineIndicator();
    }

    render() {
      if (!store.state.settings.showTimeline) return null;
      if (!store.state.timescale) return null;

      const time = Time.getFriendlyTime(store.state.timescale);
      const $timeAxis = $("<div class='time-axis' />");
      const $scaleEnd = $("<div class='scale-end' />");
      const $head = $("<div class='scale-head' />")
        .text(`${time.value} ${time.unit}`);

      const indicators = store.state.domainModel.map((g, i) => this.renderIndicator(g, i));
      const scales = Array(time.value).fill().map((_, i) => this.renderScale(i, time.value));

      return $("<div class='timeline' />")
        .append($head)
        .append($timeAxis)
        .append(scales)
        .append($scaleEnd)
        .append(indicators)
        .attr("cu-style", store.state.settings.indicatorStyle);
    };

    renderIndicator(group, index) {
      const isInactive = index >= store.state.settings.maxGroups;
      return $("<div class='indicator' />")
        .attr("all-passed", group[allPassedKey])
        .attr("inactive", isInactive)
        .text(index + 1)
        .data(availableAtMsKey, group[availableAtMsKey]);
    };

    renderScale(index, total) {
      const left = index / total * 100;
      return $("<div class='scale' />").css({'left': `calc(${left}% - 1px)`}); // -1 to center
    }

    getIndicatorCss(relative, halfIndicatorWidth) {
      if (relative < 0) {
        return {
          'left': `-${halfIndicatorWidth}px`
        };
      }
      if (relative > 100) {
        return {
          'visibility': 'hidden'
        }
      }
      return {
        'left': `calc(${relative}% - ${halfIndicatorWidth}px)`
      }
    };

    updateTimelineIndicator() {
      const timescale = store.state.timescale;
      this.$element.find(".indicator").each((i, e) => {
        const $indicator = $(e);
        const availableAt = $indicator.data(availableAtMsKey);
        const relative = (availableAt - store.state.timestamp) / timescale * 100;
        const halfIndicatorWidth = $indicator.outerWidth() / 2;
        $indicator.css(this.getIndicatorCss(relative, halfIndicatorWidth));
      });
    }
  }

  class MainComponent extends Component {
    constructor() {
      super()
    }
    intervalUpdate() {
      store.dispatch("updateUi");
    };

    resizeUpdate() {
      store.events.publish("refresh-ui");
    };

    getShadowRoot() {
      return this.shadowRoot || this.attachShadow({mode: 'open'});
    }

    componentDidMount() {
      window.addEventListener("resize", this.resizeUpdate);
      this.intervalId = setInterval(this.intervalUpdate, 1000 * 60); // 60s
      store.dispatch("updateUi");
    }

    componentDidDismount() {
      window.removeEventListener("resize", this.resizeUpdate);
      clearInterval(this.intervalId);
    }

    _render() {
      $(this.getShadowRoot())
        .html(this.render());
    }

    render() {
      const $head = this.createHeading();
      const $styles = this.renderStyles();

      if (!store.state.domainModel.length){
        return null;
      }

      return $("<div class='root' />")
        .append($styles)
        .append($head)
        .append("<cu-settings class='hidden' />")
        .append("<cu-timeline />")
        .append("<cu-group-grid />");
    }

    createHeading() {
      const $settingsBtn = $("<span class='btn btn-settings'><i class='icon icon-cog' /></span>")
        .on("click", (e) => store.events.publish("settings-btn-clicked", e));
      return $("<h2 />")
        .append("<span>Upcoming</span>")
        .append($settingsBtn)
    }

    renderStyles() {
      return $("<style/>").text(
        '.value { position: relative; font-size: 20px; height: 30px; line-height: 30px; text-align: center; box-shadow: inset 0 -2px 0 rgba(0,0,0,0.2); color: #fff; text-shadow: 0 2px 0 rgba(0,0,0,0.3); font-family: "Hiragino Kaku Gothic Pro", "Meiryo", "Source Han Sans Japanese", "NotoSansCJK", "TakaoPGothic", "Yu Gothic", "ヒラギノ角ゴ Pro W3", "メイリオ", "Osaka", "MS PGothic", "MS Pゴシック", "Noto Sans JP", sans-serif;}' +
        '.radical-icon { background-color: #00a1f1; background-image: linear-gradient(to bottom, #0af, #0093dd); background-repeat: repeat-x; }' +
        '.kanji-icon { background-color: #f100a1; background-image: linear-gradient(to bottom, #f0a, #dd0093); background-repeat: repeat-x; }' +
        'cu-group-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, auto)); grid-gap: 15px 20px; }' +
        '.grid { position: relative; display: grid; grid-gap: 4px 4px; grid-template-columns: repeat(auto-fill, 30px); height: 30px; justify-content: space-between; overflow: hidden; }' +
        '.root { font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; }' +
        'h2 { color: #555; font-weight: 400; font-size: 20.25px; margin-bottom: 5px; }' +
        '.root:hover .btn-settings { opacity: 1; }' +
        '.btn { user-select: none; cursor: pointer; }' +
        '.btn-settings { opacity:0; transition: opacity .3s ease-out; padding: 4px 12px; font-size: 16px; line-height: 20px; color: #888; }' +
        '.icon { font-family: FontAwesome; font-style: normal; }' +
        '.icon-bolt::before { content: "\\f0e7" }' +
        '.icon-cog::before { content: "\\f013"; }' +
        '.icon-close::before { content: "\\f00d"; }' +
        '.btn-settings:hover { color: #333; }' +
        '.settings { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, auto)); grid-gap: 15px 20px; align-items: center; margin-bottom: 10px; }' +
        '.setting {  display: grid; grid-template-columns: 120px auto; column-gap: 10px; align-items: center; }' +
        '.select { width: 120px; border: 2px solid; padding: 4px 6px; border-radius: 4px; }' +
        '.aside { font-family: "Ubuntu", Helvetica, Arial, sans-serif; }' +
        '.group-head { position: relative; display: flex; padding-top: 2px; background: linear-gradient(180deg, #fff 0%, transparent calc(100%)); margin-bottom: 5px; box-shadow: 0px 1px 0 0 rgba(0,0,0,0.2); }' +
        '.group-number { width: 18px; text-align: center; color: #fff; font-size: 11.844px; font-weight: bold; line-height: 18px; vertical-align: baseline; text-shadow: 0 -1px 0 rgba(0,0,0,0.25); }' +
        '.group-number[all-passed="true"] { background-color: #08C66C; }' +
        '.group-number[all-passed="false"] { background-color: #7000a9; }' +
        '.group-head .group-number { border-radius: 0; }' +
        '.group-time { font-size: 11.844px; color: rgba(0,0,0,0.6); padding: 2px 3px 0; line-height: 14px; }' +
        '.character-image { height: 1em; vertical-align: middle; }' +
        '.timeline { height: 16px; margin-bottom: 13px; position: relative; }' +
        '.timeline:empty { display: none; }' +
        '.time-axis { position: absolute; left: 0; right: 0; top: 0; height: 1px; border-top: 1px solid white; border-bottom: 1px solid white; box-shadow: inset 0px 2px 0 0 rgba(0,0,0,0.1); background-color: #d8d8d8; }' +
        '.indicator { position: absolute; transition: left .5s ease-out; }' +
        '[inactive="true"] { opacity: .3; }' +
        '[cu-style="circle"] { margin-left: 8px; margin-right: 8px; }' +
        '[cu-style="circle"] .time-axis { margin-left: -8px; margin-right: -8px; }' +
        '[cu-style="circle"] .indicator { top: 2px; width: 16px; padding: 1px 0; font-size: 11.844px; line-height: 14px; border-radius: 8px; color: #fff; text-align: center; }' +
        '[cu-style="circle"] .indicator[all-passed="true"] { background-color: #08C66C; }' +
        '[cu-style="circle"] .indicator[all-passed="false"] { background-color: #7000a9; }' +
        '[cu-style="circle"] .scale-end { width: 8px; }' +
        '[cu-style="cone"] { margin-left: 5px; margin-right: 5px; }' +
        '[cu-style="cone"] .time-axis { margin-left: -5px; margin-right: -5px; }' +
        '[cu-style="cone"] .indicator { top: 3px; padding: 0; width: 0px;  height: 0px; color: transparent; border-left: 5px solid transparent; border-right: 5px solid transparent; border-bottom: 10px solid; }' +
        '[cu-style="cone"] .indicator[all-passed="true"] { border-bottom-color: #08C66C; }' +
        '[cu-style="cone"] .indicator[all-passed="false"] { border-bottom-color: #7000a9; }' +
        '[cu-style="cone"] .scale-end { width: 5px; }' +
        '.tag { background-color: #fff; color: rgba(0,0,0,0.6); line-height: 14px; padding: 2px 4px; font-size: 11.844px; }' +
        '.spacer { flex-grow: 1; }' +
        '.scale { position: absolute; top: 2px; height: 6px; border-left: 1px solid #c8c8c8; border-right: 1px solid white; }' +
        '.scale-end { position: absolute; left: 100%; top: 2px; height: 6px; width: 8px; background: linear-gradient(135deg, #d8d8d8 50%, transparent calc(50% + 1px)); }' +
        '.scale-head { position: absolute; right: 0; bottom: 100%; font-size: 11.844px; line-height: 1em; color: #777; font-weight: 300; text-shadow: 0 1px 0 #fff;}' +
        '.overflow-icon { font-size: 12px; width: 100%; background: linear-gradient(to bottom, #5571e2, #294ddb); }' +
        '.value .overflow-icon { position: absolute; top:0; left: 0; }' +
        '.show-overflow { height: auto; }'+
        '.hidden { display: none; }'
      );
    }
  }

  class SettingsComponent extends Component {
    constructor() {
      super({
        store
      });
      this.toggleSettings = this.toggleSettings.bind(this);
    }

    componentDidMount() {
      store.events.subscribe("settings-btn-clicked", this.toggleSettings)
    }

    componentDidDismount() {
      store.events.unsubscribe("settings-btn-clicked", this.toggleSettings)
    }

    render() {
      const $maxGroups = this.renderDropdown({
        options: [
          {text: "2", value: 2},
          {text: "4", value: 4},
          {text: "6", value: 6},
          {text: "8", value: 8},
          {text: "10", value: 10},
          {text: "99", value: 99}],
        id: "max-groups",
        label: "Max No. of groups displayed",
        selected: store.state.settings.maxGroups,
        action: "setMaxGroups"
      });
      const $showTimeline = this.renderDropdown({
        options: [
          {text: "Hidden", value: "hidden"},
          {text: "Fit Groups", value: "fitGroups"},
          {text: "Fit All", value: "fitAll"},
          {text: "Static", value: "static"}],
        id: "show-timeline",
        label: "Timeline Style",
        selected: store.state.settings.showTimeline,
        action: "setShowTimeline"
      });
      const $staticTimescale = this.renderStaticTimescaleDropdown();
      const $showPassedItems = this.renderDropdown({
        options: [
          {text: "Yes", value: true},
          {text: "No", value: false}],
        id: "show-passed-items",
        label: "Show Passed Items",
        description: "Include items of this level that you already guru'ed.",
        selected: store.state.settings.showPassedItems,
        action: "setShowPassedItems"
      });
      const $indicatorStyle = this.renderDropdown({
        options: [
          {text: "Circle", value: "circle"},
          {text: "Cone", value: "cone"}],
        id: "select-indicator-style",
        label: "Timeline Indicator Style",
        selected: store.state.settings.indicatorStyle,
        action: "setIndicatorStyle"
      });

      return $("<div class='settings' />")
        .append($showTimeline)
        .append($staticTimescale)
        .append($showPassedItems)
        .append($indicatorStyle)
        .append($maxGroups)
    }
    renderStaticTimescaleDropdown() {
      return this.renderDropdown({
        options: timescaleSetHours.map(this.createTimescaleOption),
        id: "static-timescale",
        label: "Static Timescale",
        selected: store.state.settings.staticTimescaleMs,
        action: "setStaticTimescale",
        disabled: (store.state.settings.showTimeline !== "static")
      });
    }
    createTimescaleOption(h) {
      const ms = Time.hoursToMs(h);
      return {
        text: Time.getFriendlyTimeLiteral(ms),
        value: ms
      };
    }
    renderDropdown(opt) {
      const options$ = opt.options.map(o => this.createOption(opt, o));
      const $label = $("<label class='label' />")
        .text(opt.label)
        .attr("for", opt.id);
      const $aside = $("<aside class='aside' />")
        .text(opt.description);
      const $description = $("<div />")
        .append($label)
        .append($aside);
      const $select = $("<select class='select' />")
        .attr("id", opt.id)
        .prop("disabled", opt.disabled)
        .addClass(opt.className)
        .append(options$)
        .on("change", (e) => store.dispatch(opt.action, JSON.parse(e.target.value)));
      return $("<div class='setting' />")
        .append($select)
        .append($description);
    }

    createOption(options, option) {
      return $("<option />")
        .attr("value", JSON.stringify(option.value))
        .text(option.text)
        .prop("selected", options.selected === option.value);
    }

    toggleSettings(e) {
      this.$element.toggleClass("hidden");
    }
  }

// set up
  customElements.define('cu-main', MainComponent);
  customElements.define('cu-group-grid', GroupGridComponent);
  customElements.define('cu-settings', SettingsComponent);
  customElements.define('cu-timeline', TimelineComponent);

  $("[data-react-class='Progress/Progress'] > div > div")
    .first()
    .after("<cu-main />");

}

if (window.chrome && window.chrome.runtime && window.chrome.runtime.id) {
  // Sandbox
  RunInPage(bootstrap);
} else {
  bootstrap();
}