LZT Stats

Detailed statistics of your activity

目前為 2023-09-18 提交的版本,檢視 最新版本

// ==UserScript==
// @name         LZT Stats
// @namespace    lzt-stats
// @version      1.01
// @description  Detailed statistics of your activity
// @author       Toil
// @license      MIT
// @match        https://zelenka.guru/*
// @match        https://lolz.live/*
// @match        https://lolz.guru/*
// @match        https://lzt.market/*
// @match        https://lolz.market/*
// @match        https://zelenka.market/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=zelenka.guru
// @require      https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js
// @supportURL   https://zelenka.guru/toil/
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_addStyle
// ==/UserScript==

(function() {
  'use strict';

  // * STYLES
  GM_addStyle(`
    .LZTStatsTabs {
      width: 100%;
      box-sizing: border-box;
      padding: 0 10px;
      border: none !important;
      margin: 15px auto !important;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    @media screen and (max-width: 699px) {
      .LZTStatsTabs {
        flex-direction: column;
      }
    }

    .LZTStatsTab {
      position: relative;
      padding: 10px;
      margin: 0 4px;
      float: left;
      font-weight: 600;
      list-style: none !important;
      font-size: 14px;
    }

    .LZTStatsTab:hover {
      cursor: pointer;
    }

    .LZTStatsTab.active {
      box-shadow: inset 0px -2px 0px 0px #0daf77;
      transform: translateY(-1px);
      transition: .2s;
    }

    .LZTStatsTab:not(.active):hover {
      box-shadow: inset 0px -2px 0px 0px rgb(54, 54, 54);
    }

    .LZTStatsInfo {
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
    }

    @media screen and (max-width: 699px) {
      .LZTStatsInfo {
        flex-direction: column;
        align-content: center;
      }
    }

    .LZTStatsInfo .LZTStatsItem {
      height: 63px;
      background: #2D2D2D;
      border-radius: 8px;
      padding: 12px;
      box-sizing: border-box;
      display: flex;
      align-items: center;
      gap: 12px;
      white-space: normal;
    }

    @media screen and (min-width: 700px) {
      .LZTStatsInfo .LZTStatsItem {
        width: 32%;
      }
    }

    @media screen and (max-width: 699px) {
      .LZTStatsInfo .LZTStatsItem {
        width: 90%;
      }
    }

    .LZTStatsInfo .LZTStatsItem i {
      width: 24px;
      height: 24px;
      font-size: 24px;
    }

    .LZTStatsInfo .LZTStatsItem p {
      font-weight: 400;
      font-size: 13px;
      line-height: 16px;
      color: #D6D6D6;
      opacity: 0.8;
      margin-bottom: 6px;
      max-width: 95px;
    }

    .LZTStatsInfo .LZTStatsItem .LZTStatsChangeInfo {
      height: 16px;
      margin-left: auto;
      display: flex;
    }

    .LZTStatsInfo .LZTStatsItem .LZTStatsChangeInfo i {
      width: 16px;
      height: 16px;
      font-size: 16px;
      margin-left: 2px;
    }

    #LZTStatsModalTitle {
      text-align: center;
      padding: 16px;
      font-size: 20px;
      font-weight: bold;
    }

    .LZTStatsChatComment {
      background: rgb(54, 54, 54);
      margin: 5px 15px;
      padding: 10px 15px;
      border-radius: 10px;
    }

    .LZTStatsSectionItem {
      max-width: 580px;
      flex-basis: 50%;
      flex-grow: 1;
      height: 64px;
      display: flex;
      align-items: center;

      transition: all 0.5s ease;
    }

    .LZTStatsSectionItem:hover {
      background: rgba(54, 54, 54, 0.75);
      border-radius: 8px;
      cursor: pointer;
    }

    .LZTUpSectionTextContainer {
      display: flex;
      flex-direction: column;
      justify-content: center;
      flex: 1 1 auto;
      max-width: 100%;
    }

    .LZTStatsSectionItem i {
      width: 28px;
      height: 28px;
      margin: 20px;
      font-size: 28px;
      color: #0daf77;
    }

    .LZTStatsSectionTitle {
      display: block;
      margin-right: 20px;
      font-size: 15px;
      font-weight: bold;
      text-overflow: ellipsis;
      white-space: nowrap;
      overflow: hidden;
    }

    #LZTStatsLoadChatHistory, .LZTStatsTotalMessages {
      margin: 15px;
    }
  `)

  // * CONSTANTS
  const HOUR_IN_SECS = 3600;
  const DAY_IN_SECS = HOUR_IN_SECS * 24;
  const MONTH_IN_SECS = DAY_IN_SECS * 30;

  // * REGEXES
  const SEND_IN_THREAD = /^threads\/([^d]+)\/add-reply$/
  const COMMENT_IN_THREAD = /^posts\/([^d]+)\/comment$/

  const DELETE_MESSAGE_IN_THREAD = /^posts\/([^d]+)\/delete$/
  const DELETE_COMMENT_IN_THREAD = /^posts\/comments\/([^d]+)\/delete$/

  const EDITED_MESSAGE_IN_THREAD = /^posts\/([^d]+)\/save-inline$/
  const EDITED_COMMENT_IN_THREAD = /^posts\/comments\/([^d]+)\/save-inline$/

  const PARTICIPATE_IN_CONTEST = /^threads\/([^d]+)\/participate/ // ! DON'T ADD ANYTHING TO THE END!! THERE'S A CAPTCHA GOING ON
  const SYMPATHY_IN_THREAD = /\/posts\/([^d]+)\/like$/
  const REPORT_IN_THREAD = /^posts\/([^d]+)\/report$/

  const SEND_IN_PROFILE_USERID = /^members\/([^d]+)\/post$/
  const SEND_IN_PROFILE_TAG = /^([-\w]+)\/post$/
  const COMMENT_IN_PROFILE = /^profile-posts\/([^d]+)\/comment$/

  const POLL_IN_THREAD = /^threads\/([^d]+)\/poll\/vote/

  const SEND_IN_CHAT = /^\/chatbox\/post-message$/
  const EDIT_IN_CHAT = /^\/chatbox\/([^d]+)\/edit$/
  const DELETE_IN_CHAT = /^\/chatbox\/([^d]+)\/delete$/

  const CREATE_NEW_THREAD = /^forums\/([-\w]+)\/add-thread$/
  const EDIT_THREAD = /^threads\/([^d]+)\/save$/
  const DELETE_THREAD = /^threads\/([^d]+)\/delete$/

  const SEND_IN_CONVERSATION = /^conversations\/([^d]+)\/insert-reply$/
  const EDIT_IN_CONVERSATION = /^conversations\/([^d]+)\/save-message/ // ! DON'T ADD ANYTHING TO THE END!! THERE GOES THE MESSAGE NUMBER

  const FIND_THREAD_ID = /^threads\/([^d]+)\//
  const FIND_POST_ID = /^posts\/([^d]+)\//
  const FIND_USER_POST = /^([-\w]+)\/post$/
  const FIND_USER_ID = /^members\/([^d]+)\//
  const FIND_COMMENT_POST_ID = /^posts\/comments\/([^d]+)\//
  const FIND_FORUM_ID = /^forums\/([-\w]+)\//
  const FIND_POST_ID_IN_FULL_URL = /\/posts\/([^d]+)\//
  const FIND_CHAT_ID = /^\/chatbox\/([^d]+)\//
  const FIND_CONVERSATION_ID = /^conversations\/([^d]+)\//

  // * HOOKS
  function initHooks() {
    // reference: https://github.com/LOLZHelper/LOLZHelperReborn/blob/main/src/common/hooks.js
    const XF_AJAX = XenForo.ajax;

    XenForo.ajax = function () {
      console.debug('[LZTStats.initHooks] [XenForo.ajax]', arguments);
      const url = arguments[0];
      const data = arguments[1];
      const success = arguments[2];
      const options = arguments[3];

      const timestamp = getTimestamp();
      switch (true) {
        case SEND_IN_THREAD.test(url):
          {
            const threadId = getThreadIdByURL(url);
            saveStats({
              id: threadId,
              timestamp
            }, 'stats-messages')
            break;
          }
        case COMMENT_IN_THREAD.test(url):
          {
            const postId = getPostIdByURL(url);
            saveStats({
              id: postId,
              timestamp
            }, 'stats-comments')
            break;
          }
        case PARTICIPATE_IN_CONTEST.test(url):
          {
            const threadId = getThreadIdByURL(url);
            saveStats({
              id: threadId,
              timestamp
            }, 'stats-participates');
            break;
          }
        case SYMPATHY_IN_THREAD.test(url):
          {
            // ! SYMPATHIES AND LIKES HAVE THE SAME URL
            const postId = getPostIdByFullURL(url);
            saveStats({
              id: postId,
              timestamp
            }, 'stats-sympathies');
            break;
          }
        case REPORT_IN_THREAD.test(url):
          {
            const postId = getPostIdByURL(url);
            saveStats({
              id: postId,
              // message: data?.[1] || '', // weighs a lot
              timestamp
            }, 'stats-reports');
            break;
          }
        case CREATE_NEW_THREAD.test(url):
          {
            const forumId = getForumIdByURL(url);
            saveStats({
              id: forumId,
              timestamp
            }, 'stats-threads-created')
            break;
          }
        case EDIT_THREAD.test(url):
          {
            const threadId = getThreadIdByURL(url);
            saveStats({
              id: threadId,
              timestamp
            }, 'stats-threads-edited')
            break;
          }
        case DELETE_THREAD.test(url):
          {
            const threadId = getThreadIdByURL(url);
            saveStats({
              id: threadId,
              timestamp
            }, 'stats-threads-deleted')
            break;
          }
        case (DELETE_COMMENT_IN_THREAD.test(url) && data.length > 0):
          {
            const postId = getCommentPostIdByURL(url);
            saveStats({
              id: postId,
              timestamp
            }, 'stats-comments-deleted')
            break;
          }
        case (DELETE_MESSAGE_IN_THREAD.test(url) && data.length > 0):
          {
            // ! Do not switch places with comments or you will have to fix the regex
            const postId = getPostIdByURL(url);
            saveStats({
              id: postId,
              timestamp
            }, 'stats-messages-deleted')
            break;
          }
        case EDITED_COMMENT_IN_THREAD.test(url):
          {
            const postId = getCommentPostIdByURL(url);
            saveStats({
              id: postId,
              timestamp
            }, 'stats-comments-edited')
            break;
          }
        case (EDITED_MESSAGE_IN_THREAD.test(url)):
          {
            // ! Do not switch places with comments or you will have to fix the regex
            const postId = getPostIdByURL(url);
            saveStats({
              id: postId,
              timestamp
            }, 'stats-messages-edited')
            break;
          }
        case (POLL_IN_THREAD.test(url) && data.length > 0):
          {
            const threadId = getThreadIdByURL(url);
            saveStats({
              id: threadId,
              timestamp
            }, 'stats-polls')
            break;
          }
        case (SEND_IN_PROFILE_USERID.test(url) || (SEND_IN_PROFILE_TAG.test(url) && (window.location.pathname.replace('/', '') + '/post') === url)):
          {
            // Check by ID, if the user has a tag, then check so that the path matches the name in the link
            const userId = getUserIdByURL(url); // it can also return the tag
            saveStats({
              id: userId,
              timestamp
            }, 'stats-messages');
            break;
          }
        case (COMMENT_IN_PROFILE.test(url)):
          {
            const postId = getCommentPostIdByURL(url);
            saveStats({
              id: postId,
              timestamp
            }, 'stats-comments');
            break;
          }
        case (SEND_IN_CHAT.test(url)):
          {
            saveStats({
              id: 0,
              message: data?.message,
              timestamp
            }, 'stats-chat');
            break;
          }
        case (EDIT_IN_CHAT.test(url)):
          {
            const chatId = getChatIdByURL(url);
            saveStats({
              id: chatId,
              timestamp
            }, 'stats-chat-edited');
            break;
          }
        case (DELETE_IN_CHAT.test(url)):
          {
            const chatId = getChatIdByURL(url);
            saveStats({
              id: chatId,
              timestamp
            }, 'stats-chat-deleted');
            break;
          }
        case (SEND_IN_CONVERSATION.test(url)):
          {
            const conversationId = getConversationIdByURL(url);
            saveStats({
              id: conversationId,
              timestamp
            }, 'stats-conversation');
            break;
          }
        case (EDIT_IN_CONVERSATION.test(url)):
          {
            const conversationId = getConversationIdByURL(url);
            saveStats({
              id: conversationId,
              timestamp
            }, 'stats-conversation-edited');
            break;
          }
        default:
          break;
      }

      return XF_AJAX.apply(this, arguments);
    }
  }

  // * REGISTERS
  function regMenuBtn(name) {
    const menuBtn = document.createElement('li');

    const link = document.createElement('a');
    link.classList.add('bold');
    link.style.color = '#0daf77';
    link.innerText = name;

    const separator = document.createElement('div');
    separator.classList.add('account-menu-sep');

    menuBtn.appendChild(link);

    const latestMenuItem = document.querySelector('#AccountMenu > .blockLinksList > li:last-child');
    latestMenuItem.insertAdjacentElement('beforebegin', menuBtn);
    latestMenuItem.insertAdjacentElement('beforebegin', separator);

    return menuBtn;
  }

  function regModal(name, mainEl = '') {
    return XenForo.alert(mainEl, name, null, () => {
      document.querySelector('div.modal.fade').remove();
    })
  }

  function setMenuTitle(modal, title) {
    const modalOverlay = modal?.[0];
    const modalTitle = modalOverlay?.querySelector('h2.heading');
    modalTitle.id = 'LZTStatsModalTitle';
    modalTitle.innerText = title;
  }

  // * CLASSES
  class Tab {
    /**
     *
     *  @constructor
     *  @param {string} name - name of the tab
     *  @param {string} tabId - id of the tab
     *  @param {string} sectionId - id of the section
     *  @param {boolean} active - status of tab
     */

    constructor(name, tabId, sectionId, active) {
      this.name = name;
      this.tabId = tabId;
      this.sectionId = sectionId;
      this.active = active;
    }

    createElement() {
      const tab = document.createElement('li');
      tab.classList.add('LZTStatsTab');
      tab.id = this.tabId;

      const span = document.createElement('span');
      span.innerText = this.name;

      tab.appendChild(span);
      tab.addEventListener('click', () => this.setActive());
      return tab;
    }

    setActive() {
      document.querySelectorAll('.LZTStatsTab').forEach(tab => tab.classList.remove('active'));

      document.getElementById(this.tabId).classList.add('active');

      document.querySelectorAll('.LZTStatsModalContent > .LZTStatsSection').forEach(section => section.style.display = 'none');

      document.getElementById(this.sectionId).style.display = '';
    }
  }


  // * HELPERS
  function renderMenu() {
    const menuBtn = regMenuBtn('LZT Stats');
    menuBtn.addEventListener('click', async () => {
      // * CREATE MODAL
      const modal = regModal('LZT Stats', '<div class="LZTStatsModalContent"></div>');
      setMenuTitle(modal, 'LZT Stats');

      const modalContent = document.querySelector('.LZTStatsModalContent');

      const tabsContainer = document.createElement('ul');
      tabsContainer.classList.add('LZTStatsTabs');

      /**
       * items - list of objects (usually: id, timestamp)
       * label - title of the item
       * data - converted items to work with graph
       * icon - icon of the item
       * changeValue - Value compared to last time
       * hidden - default visibility in graph
       */
      const statsData = [
        {
          items: await GM_getValue('stats-messages') || [],
          label: 'Сообщений',
          icon: 'far fa-comment-alt',
          data: [],
          changeValue: null,
          hidden: false
        },
        {
          items: await GM_getValue('stats-messages-edited') || [],
          label: 'Изменено сообщений',
          icon: 'far fa-comment-alt-edit',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-messages-deleted') || [],
          label: 'Удалено сообщений',
          icon: 'far fa-comment-alt-times',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-comments') || [],
          label: 'Комментариев',
          icon: 'far fa-comment-alt-dots',
          data: [],
          changeValue: null,
          hidden: false
        },
        {
          items: await GM_getValue('stats-comments-edited') || [],
          label: 'Изменено комментариев',
          icon: 'far fa-comment-alt-edit',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-comments-deleted') || [],
          label: 'Удалено комментариев',
          icon: 'far fa-comment-alt-times',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-chat') || [],
          label: 'Сообщений в чате',
          icon: 'far fa-comments',
          data: [],
          changeValue: null,
          hidden: false
        },
        {
          items: await GM_getValue('stats-chat-edited') || [],
          label: 'Изменено в чате',
          icon: 'far fa-comment-edit',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-chat-deleted') || [],
          label: 'Удалено из чата',
          icon: 'far fa-comment-times',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-threads-created') || [],
          label: 'Создано тем',
          icon: 'far fa-file-alt',
          data: [],
          changeValue: null,
          hidden: false
        },
        {
          items: await GM_getValue('stats-threads-edited') || [],
          label: 'Изменено тем',
          icon: 'far fa-file-edit',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-threads-deleted') || [],
          label: 'Удалено тем',
          icon: 'far fa-file-times',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-participates') || [],
          label: 'Участий в розыгрышах',
          icon: 'far fa-gift',
          data: [],
          changeValue: null,
          hidden: false,
        },
        {
          items: await GM_getValue('stats-sympathies') || [],
          label: 'Поставлено лайков/симп',
          icon: 'far fa-heart',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-reports') || [],
          label: 'Репортов',
          icon: 'far fa-bullhorn',
          data: [],
          changeValue: null,
          hidden: false
        },
        {
          items: await GM_getValue('stats-polls') || [],
          label: 'Пройдено опросов',
          icon: 'far fa-poll',
          data: [],
          changeValue: null,
          hidden: true
        },
        {
          items: await GM_getValue('stats-conversation') || [],
          label: 'Сообщений в ЛС',
          icon: 'far fa-comments-alt',
          data: [],
          changeValue: null,
          hidden: false
        },
        {
          items: await GM_getValue('stats-conversation-edited') || [],
          label: 'Изменено в ЛС',
          icon: 'far fa-comment-alt-edit',
          data: [],
          changeValue: null,
          hidden: true
        },
      ]

      // ** STATS BY ALL TIME SECTION
      const allTimeSection = document.createElement('div');
      allTimeSection.classList.add('LZTStatsSection')
      allTimeSection.id = 'LZTStatsAllSection';
      await createTimeSection(allTimeSection, statsData, 12, MONTH_IN_SECS);

      // ** STATS BY ALL MONTH SECTION
      const monthTimeSection = document.createElement('div');
      monthTimeSection.classList.add('LZTStatsSection')
      monthTimeSection.id = 'LZTStatsMonthSection';
      await createTimeSection(monthTimeSection, statsData, 30, DAY_IN_SECS);

      // ** STATS BY ALL WEEK SECTION
      const weekTimeSection = document.createElement('div');
      weekTimeSection.classList.add('LZTStatsSection')
      weekTimeSection.id = 'LZTStatsWeekSection';
      await createTimeSection(weekTimeSection, statsData, 7, DAY_IN_SECS);

      // ** STATS BY ALL DAY SECTION
      const dayTimeSection = document.createElement('div');
      dayTimeSection.classList.add('LZTStatsSection')
      dayTimeSection.id = 'LZTStatsDaySection';
      await createTimeSection(dayTimeSection, statsData, 24, HOUR_IN_SECS);

      // ** CHAT SECTION
      const chatSection = document.createElement('div');
      chatSection.classList.add('LZTStatsSection');
      chatSection.id = 'LZTStatsChatSection';

      const loadChatHistory = document.createElement('button');
      loadChatHistory.classList.add('button', 'primary', 'fit');
      loadChatHistory.id = 'LZTStatsLoadChatHistory'
      loadChatHistory.innerText = 'Загрузить историю чата';
      loadChatHistory.onclick = async () => {
        loadChatHistory.disabled = true;
        loadChatHistory.classList.add('disabled');
        loadChatHistory.innerText = 'Загружено';

        const chatMessages = await GM_getValue('stats-chat') || [];
        const messagesEl = []
        const messagesTotalEl = document.createElement('div');
        messagesTotalEl.classList.add('LZTStatsTotalMessages');
        messagesTotalEl.innerText = `Всего сообщений: ${chatMessages.length}`

        for (const msg of chatMessages.reverse()) {
          const chatEl = document.createElement('p');
          const chatTimeEl = document.createElement('p');
          chatEl.classList.add('LZTStatsChatComment')
          chatEl.innerHTML = msg?.message || 'текст не найден';
          const chatDate = new Date(msg.timestamp * 1000);
          chatTimeEl.innerText = `\n${formatDate(chatDate.getHours())}:${formatDate(chatDate.getMinutes())} ${formatDate(chatDate.getDate())}.${formatDate(chatDate.getMonth() + 1)}.${formatDate(chatDate.getFullYear())}`;
          chatEl.append(chatTimeEl);
          messagesEl.push(chatEl);
        }

        chatSection.append(messagesTotalEl, ...messagesEl)
      }
      chatSection.append(loadChatHistory);

      // ** SETTINGS SECTION
      const settingsSection = document.createElement('div');
      settingsSection.classList.add('LZTStatsSection');
      settingsSection.id = 'LZTStatsSettingsSection';

      const settingsDownloadEl = document.createElement('div');
      settingsDownloadEl.classList.add('LZTStatsSectionItem');

      const sectionIcon = document.createElement('i');
      sectionIcon.classList.add('far', 'fa-file-download')

      const textContainer = document.createElement('div');
      textContainer.classList.add('LZTStatsSectionTextContainer');

      const textEl = document.createElement('span');
      textEl.classList.add('LZTStatsSectionTitle');
      textEl.innerText = 'Скачать собранные данные';

      textContainer.append(textEl);
      settingsDownloadEl.append(sectionIcon, textContainer);
      settingsSection.append(settingsDownloadEl)

      settingsDownloadEl.onclick = () => {
        downloadJSONFile(JSON.stringify(statsData.map(e => e.items)), 'LZTStats')
      }

      // ** MODAL EXECUTORS
      modalContent.append(tabsContainer, allTimeSection, monthTimeSection, weekTimeSection, dayTimeSection, chatSection, settingsSection);

      const tabs = [
        new Tab('За год', 'LZTStatsAllTab', 'LZTStatsAllSection', true),
        new Tab('За месяц', 'LZTStatsMonthTab', 'LZTStatsMonthSection', false),
        new Tab('За неделю', 'LZTStatsWeekTab', 'LZTStatsWeekSection', false),
        new Tab('За день', 'LZTStatsDayTab', 'LZTStatsDaySection', false),
        new Tab('Чат', 'LZTStatsChatTab', 'LZTStatsChatSection', false),
        new Tab('Настройки', 'LZTStatsSettingsTab', 'LZTStatsSettingsSection', false),
      ]

      for (const tab of tabs) {
        tabsContainer.appendChild(tab.createElement());
        tab.active ? tab.setActive() : null;
      }
    })
  }

  async function createTimeSection(containerEl, statsData, GRAPH_LENGTH, GRAPH_TIME_FORMAT) {
    statsData.forEach(s => s.data = getSumValuesByTime(calcSumByTime(s.items, GRAPH_TIME_FORMAT, GRAPH_LENGTH)))
    const currentTimestamp = getTimestamp();

    const calculatedGraphTime = GRAPH_TIME_FORMAT * GRAPH_LENGTH;
    const lastPageDate = currentTimestamp - (calculatedGraphTime * 2)
    const thisPageDate = currentTimestamp - calculatedGraphTime

    if (GRAPH_TIME_FORMAT !== MONTH_IN_SECS) {
      // skip year format info
      statsData.forEach(s => s.changeValue = s.items.filter(i => i.timestamp > thisPageDate).length - s.items.filter(i => i.timestamp > lastPageDate && i.timestamp < thisPageDate).length);
    }

    const timeArray = getTimeArray(GRAPH_TIME_FORMAT, GRAPH_LENGTH);

    const statsContainer = document.createElement('div');
    statsContainer.classList.add('LZTStatsInfo')

    const statsItems = statsData.map(s => createStatsItem(s.label, s.items.filter(i => i.timestamp > thisPageDate).length, s.icon, s.changeValue));
    statsContainer.append(...statsItems);

    const graph = document.createElement('canvas');
    if (document.querySelector('.xenOverlay.slim')) {
      graph.width = 300;
      graph.height = 600;
    } else {
      graph.width = 600;
      graph.height = 450;
    }
    graph.id = `LZTStatsGraph-${GRAPH_LENGTH}-${GRAPH_TIME_FORMAT}`;

    containerEl.append(statsContainer, graph);

    new Chart(graph, {
      type: 'line',
      data: {
        labels: timeArray.reverse(),
        datasets: statsData.map(s => ({
          label: s.label,
          data: s.data,
          borderWidth: 1,
          hidden: s.hidden
        }))
      },
      options: {
        scales: {
          y: {
            beginAtZero: true
          }
        },
        onClick: (e) => {
          console.log(e)
        }
      }
    });
  }

  function createStatsItem(title, value, iconClasses = '', changeValue = null) {
    const item = document.createElement('div');
    item.classList.add('LZTStatsItem');

    const iconContainer = document.createElement('div');
    const icon = document.createElement('i');
    icon.classList.add(...iconClasses.split(' ')); // convert to the view that FontAwesome works with
    iconContainer.appendChild(icon)

    const textContainer = document.createElement('div');
    textContainer.innerText = value
    const textTitle = document.createElement('p');
    textTitle.innerText = title;
    textContainer.insertAdjacentElement('afterbegin', textTitle)

    item.append(iconContainer, textContainer)

    if (typeof changeValue === 'number') {
      const changeContainer = document.createElement('div');
      changeContainer.classList.add('LZTStatsChangeInfo')
      changeContainer.innerText = changeValue;
      const changeIcon = document.createElement('i');

      // Set icon and style by number that has changed
      // default: Just like last time
      let changeIconClasses = 'far fa-arrows-alt-v'
      let changeIconStyle = '#D6D6D6';
      if (changeValue < 0) {
        // Less than last time
        changeIconClasses = 'fas fa-caret-down'
        changeIconStyle = '#ea4c4c';
      } else if (changeValue > 0) {
        // More than last time
        changeIconClasses = 'fas fa-caret-up'
        changeIconStyle = '#0daf77';
      }

      changeIcon.classList.add(...changeIconClasses.split(' ')); // convert to the view that FontAwesome works with
      changeIcon.style = `color: ${changeIconStyle}`;
      changeContainer.appendChild(changeIcon)
      item.append(changeContainer);
    }

    return item
  }

  // * UTILS
  function getTimestamp() {
    return Math.floor(Date.now() / 1000);
  }

  function roundMinutes(date) {
    // https://stackoverflow.com/questions/7293306/how-to-round-to-nearest-hour-using-javascript-date-object
    date.setHours(date.getHours() + Math.round(date.getMinutes() / 60));
    date.setMinutes(0, 0, 0); // Resets also seconds and milliseconds

    return date;
  }

  function formatDate(dateString) {
    // format X hours, minutes and etc to 0X. Ex. 1 -> 01, 12 -> 12
    return ('0' + dateString).slice(-2);
  }

  function getThreadIdByURL(threadURL) {
    const threadId = threadURL.match(FIND_THREAD_ID)?.[1]?.split('/')?.[0];
    return Number(threadId) || 0;
  }

  function getPostIdByURL(postURL) {
    const postId = postURL.match(FIND_POST_ID)?.[1]?.split('/')?.[0];
    return Number(postId) || 0;
  }

  function getUserIdByURL(userURL) {
    const matched = userURL.match(FIND_USER_POST) || userURL.match(FIND_USER_ID);
    const userId = matched?.[1]?.split('/')?.[0];
    return String(userId) || 0;
  }

  function getCommentPostIdByURL(postURL) {
    const postId = postURL.match(FIND_COMMENT_POST_ID)?.[1]?.split('/')?.[0];
    return Number(postId) || 0;
  }

  function getForumIdByURL(forumURL) {
    const forumId = forumURL.match(FIND_FORUM_ID)?.[1]?.split('/')?.[0];
    return String(forumId) || 0;
  }

  function getPostIdByFullURL(postURL) {
    const postId = postURL.match(FIND_POST_ID_IN_FULL_URL)?.[1]?.split('/')?.[0];
    return Number(postId) || 0;
  }

  function getChatIdByURL(chatURL) {
    const chatId = chatURL.match(FIND_CHAT_ID)?.[1]?.split('/')?.[0];
    return Number(chatId) || 0;
  }

  function getConversationIdByURL(conversationURL) {
    const conversationId = conversationURL.match(FIND_CONVERSATION_ID)?.[1]?.split('/')?.[0];
    return Number(conversationId) || 0;
  }

  function calcSumByTime(data, timeFormat = DAY_IN_SECS, maxLength = 7) {
    let sepatedData = {};
    let timestamp = getTimestamp();

    while (Object.keys(sepatedData).length < maxLength) {
      const temp = data.filter(m => m.timestamp > timestamp - timeFormat && m.timestamp < timestamp);
      const date = roundMinutes(new Date((timestamp - timeFormat) * 1000));
      let dateString = `${formatDate(date.getDate())}.${formatDate(date.getMonth() + 1)}`;
      if (timeFormat === HOUR_IN_SECS) {
        dateString = `${formatDate(date.getHours())}:${formatDate(date.getMinutes())}`;
      }

      sepatedData[dateString] = temp;
      timestamp -= timeFormat;
    }

    return sepatedData;
  }

  function getSumValuesByTime(sumData) {
    return Object.values(sumData).map(m => m.length).reverse();
  }

  function getTimeArray(timeFormat = DAY_IN_SECS, maxLength = 7) {
    let timeArray = [];
    let timestamp = getTimestamp();

    for (let i = 0; i < maxLength; i++) {
      const date = roundMinutes(new Date((timestamp - timeFormat) * 1000));
      if (timeFormat === HOUR_IN_SECS) {
        timeArray.push(`${formatDate(date.getHours())}:${formatDate(date.getMinutes())}`);
      } else {
        timeArray.push(`${formatDate(date.getDate())}.${formatDate(date.getMonth() + 1)}`); // month start with 0
      }

      timestamp -= timeFormat;
    }

    return timeArray;
  }

  function downloadJSONFile(data, name) {
    const blob = new Blob([data], {
      type: 'application/json'
    });
    const link = document.createElement('a');
    link.href = window.URL.createObjectURL(blob);
    link.download = `${name}.json`;
    link.click();
    return link;
  }

  /**
   * Save stats in GM Storage
   *
   * @param {object} stats - object of stats (ex.: id, message, timestamp)
   * @param {string} valueName - name of storage
   */
  function saveStats(stats, valueName) {
    console.debug("[LZTStats.saveStats]", stats)
    let oldData = GM_getValue(valueName);
    if (oldData === undefined) {
      oldData = []
    }

    oldData.push(stats)

    GM_setValue(valueName, oldData);
  }


  // * MAIN
  function init() {
    initHooks()
    renderMenu()
  }

  init()
})();