Stats, Shards, XP, Dust, Quest, Res, Loot and Level tracker

Tracks XP/hr, XP/day, Dust/hr, Dust/day, TNL estimate, and Quest time, as well as shards/hr, shards/day, stats/hr, total stats

// ==UserScript==
// @name         Stats, Shards, XP, Dust, Quest, Res, Loot and Level tracker
// @namespace    http://tampermonkey.net/
// @version      2.7.2
// @description  Tracks XP/hr, XP/day, Dust/hr, Dust/day, TNL estimate, and Quest time, as well as shards/hr, shards/day, stats/hr, total stats
// @icon         https://www.google.com/s2/favicons?sz=64&domain=manarion.com
// @match        *://manarion.com/*
// @grant        none
// @author       Elnaeth
// @license      MIT
// ==/UserScript==

/*
 ======= Changelog =======
 v2.7.2
 Stat tracker now also tracks from game memory

 v2.7.1
 Added more tracked items, added option to manually remove single lines if desired

 v2.6.0 to v2.7.0
 Completely reworking everything to read directly from the game instead of scraping HTML
 Finally got rid of that horrible date parsing shit, things should be more stable from now on

 v2.5.1 to v2.5.2
 Started tracking ele shards in the loot tracker as well

 v2.5.0
 Added a tracker for some item drops 

 v2.4.2 all the way to 2.4.9 and with love from Germany and The Netherlands
 Added even more date parsing formats, refactored the date parsing code to be more readable and its own function

 v2.4.1
 Added a checkbox to toggle the visibility of the stats log
 Made it persist across page reloads

 v2.4.0
 Add a scrolling stat log right above the loot tracker

 v2.3.2
 Changed date parsing to try and accomodate more country formats

 v2.3.1
 Updated tracker injection code to try to locate it and retry injection until it succeeds

 v2.3.0
 Many changes and updates to date and time and number format parsing,
 not all browsers/countries adhere to the same standards (fuck ISO8601 amirite?)
*/

"use strict";

(function() {
    // track ticks that have passed
    let ticks = 0;

    // simple tracking for last known XP/Dust/Resource amounts, since those can be multiplied by ticks to extrapolate to longer periods
    let lastXP = null;
    let lastDust = null;
    let lastResource = null;

const BoostTypes = Object.freeze({
  BASE_SPELLPOWER: { id: 1, name: "Base Spellpower" },
  BASE_WARD: { id: 2, name: "Base Ward" },

  STAT_INTELLECT: { id: 3, name: "Intellect" },
  STAT_STAMINA: { id: 4, name: "Stamina" },
  STAT_FOCUS: { id: 5, name: "Focus" },
  STAT_SPIRIT: { id: 6, name: "Spirit" },
  STAT_MANA: { id: 7, name: "Mana" },

  FIRE_MASTERY: { id: 10, name: "Fire Mastery" },
  WATER_MASTERY: { id: 11, name: "Water Mastery" },
  NATURE_MASTERY: { id: 12, name: "Nature Mastery" },
});

const ItemTypes = Object.freeze({
  MANA_DUST: { id: 1, name: "Mana Dust", rarity: "common" },
  ELEMENTAL_SHARDS: { id: 2, name: "Elemental Shards", rarity: "common" },
  CODEX: { id: 3, name: "Codex", rarity: "epic" },

  FIRE_ESSENCE: { id: 4, name: "Fire Essence", rarity: "rare" },
  WATER_ESSENCE: { id: 5, name: "Water Essence", rarity: "rare" },
  NATURE_ESSENCE: { id: 6, name: "Nature Essence", rarity: "rare" },

  FISH: { id: 7, name: "Fish", rarity: "common" },
  WOOD: { id: 8, name: "Wood", rarity: "common" },
  IRON: { id: 9, name: "Iron", rarity: "common" },

  ASBESTOS: { id: 10, name: "Asbestos", rarity: "uncommon" },
  IRONBARK: { id: 11, name: "Ironbark", rarity: "uncommon" },
  FISH_SCALES: { id: 12, name: "Fish Scales", rarity: "uncommon" },

  TOME_OF_FIRE: { id: 13, name: "Tome of Fire", rarity: "uncommon" },
  TOME_OF_WATER: { id: 14, name: "Tome of Water", rarity: "uncommon" },
  TOME_OF_NATURE: { id: 15, name: "Tome of Nature", rarity: "uncommon" },

  TOME_OF_MANA_SHIELD: { id: 16, name: "Tome of Mana Shield", rarity: "epic" },

  ENCHANT_FIRE_RESISTANCE: { id: 17, name: "Formula: Fire Resistance", rarity: "epic" },
  ENCHANT_WATER_RESISTANCE: { id: 18, name: "Formula: Water Resistance", rarity: "epic" },
  ENCHANT_NATURE_RESISTANCE: { id: 19, name: "Formula: Nature Resistance", rarity: "epic" },
  ENCHANT_INFERNO: { id: 20, name: "Formula: Inferno", rarity: "epic" },
  ENCHANT_TIDAL_WRATH: { id: 21, name: "Formula: Tidal Wrath", rarity: "epic" },
  ENCHANT_WILDHEART: { id: 22, name: "Formula: Wildheart", rarity: "epic" },
  ENCHANT_INSIGHT: { id: 23, name: "Formula: Insight", rarity: "epic" },
  ENCHANT_BOUNTIFUL_HARVEST: { id: 24, name: "Formula: Bountiful Harvest", rarity: "epic" },
  ENCHANT_PROSPERITY: { id: 25, name: "Formula: Prosperity", rarity: "epic" },
  ENCHANT_FORTUNE: { id: 26, name: "Formula: Fortune", rarity: "epic" },
  ENCHANT_GROWTH: { id: 27, name: "Formula: Growth", rarity: "epic" },
  ENCHANT_VITALITY: { id: 28, name: "Formula: Vitality", rarity: "epic" },

  REAGENT_ELDERWOOD: { id: 29, name: "Elderwood", rarity: "uncommon" },
  REAGENT_LODESTONE: { id: 30, name: "Lodestone", rarity: "uncommon" },
  REAGENT_WHITE_PEARL: { id: 31, name: "White Pearl", rarity: "uncommon" },
  REAGENT_FOUR_LEAF_CLOVER: { id: 32, name: "Four Leaf Clover", rarity: "uncommon" },
  REAGENT_ENCHANTED_DROPLET: { id: 33, name: "Enchanted Droplet", rarity: "uncommon" },
  REAGENT_INFERNAL_HEART: { id: 34, name: "Infernal Heart", rarity: "uncommon" },

  ORB_OF_POWER: { id: 35, name: "Orb of Power", rarity: "rare" },
  ORB_OF_CHAOS: { id: 36, name: "Orb of Chaos", rarity: "epic" },
  ORB_OF_DIVINITY: { id: 37, name: "Orb of Divinity", rarity: "legendary" },

  SUNPETAL: { id: 39, name: "Sunpetal", rarity: "rare" },
  SAGEROOT: { id: 40, name: "Sageroot", rarity: "common" },
  BLOOMWELL: { id: 41, name: "Bloomwell", rarity: "common" },
});

// function that formats huge numbers humanly-readable
const formatNumber = (num) => {
  if (num >= 1e15) return (num / 1e15).toFixed(2) + "Qa";
  if (num >= 1e12) return (num / 1e12).toFixed(2) + "T";
  if (num >= 1e9) return (num / 1e9).toFixed(2) + "B";
  if (num >= 1e6) return (num / 1e6).toFixed(2) + "M";
  if (num >= 1e3) return (num / 1e3).toFixed(2) + "K";

  // if it's a result like 1.00 or 2.00 we trim the zeroes
  const result = (num / 1).toFixed(2).toString();
  return result.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
};

// converts minutes to 1h 30m strings
const formatTime = (minutesTotal) => {
  const hours = Math.floor(minutesTotal / 60);
  const minutes = Math.floor(minutesTotal % 60);
  return `${hours}h ${minutes}m`;
};

const sortByRarity = (a, b) => {
  const rarityOrder = {
    common: 1,
    uncommon: 2,
    rare: 3,
    epic: 4,
    legendary: 5,
  };

  return rarityOrder[a.rarity] - rarityOrder[b.rarity];
};

// keep track of what kind of thing we're doing right now
const isBattling = () => manarion.player.ActionType === "battle";
const isGathering = () => ["mining", "fishing", "woodcutting"].includes(manarion.player.ActionType);

// keep track of found stats in a nicer format for the visible stat tracker
const statLog = [];

const cleanStats = () => ({
  StartTime: null,
  EndTime: new Date(),

  TotalStats: 0,

  TrackedStats: [
    // base stats
    { definition: BoostTypes.STAT_INTELLECT, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
    { definition: BoostTypes.STAT_STAMINA, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
    { definition: BoostTypes.STAT_SPIRIT, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
    { definition: BoostTypes.STAT_FOCUS, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },
    { definition: BoostTypes.STAT_MANA, start: 0, current: 0, gained: 0, mastery: false, lastCheck: null },

    // masteries
    { definition: BoostTypes.WATER_MASTERY, start: 0, current: 0, gained: 0, mastery: true, lastCheck: null, active: false },
    { definition: BoostTypes.FIRE_MASTERY, start: 0, current: 0, gained: 0, mastery: true, lastCheck: null, active: false },
    { definition: BoostTypes.NATURE_MASTERY, start: 0, current: 0, gained: 0, mastery: true, lastCheck: null, active: false },
  ],
});

// keep track of player gained stats in absolute values
let stats = cleanStats();

const getStatByName = (partialName) => {
  return stats.TrackedStats.find((stat) => stat.definition.name.toLowerCase().includes(partialName.toLowerCase()));
};

// parse base stat values from game memory
const getStats = () => {
  // retrieve from storage
  const loadedStats = localStorage.getItem("elnaeth-stats-history");
  if (loadedStats) {
    const parsed = JSON.parse(loadedStats);
    stats = parsed;
    stats.StartTime = new Date(parsed.StartTime);
    stats.EndTime = new Date(parsed.EndTime);
  }

  // each iteration, set the end time to right now
  stats.EndTime = new Date();

  const player = manarion.player;
  if (!player) return stats;

  // find the spell type the player is currently using and set that as active
  const activeMastery = stats.TrackedStats.find((x) => x.definition.name.toLowerCase().includes(player.MagicType));
  activeMastery.active = true;

  const playerBoosts = manarion.player.Boosts;
  if (!playerBoosts) return stats;

  // loop over all keys, which are the ID of the boost
  for (const boostID in playerBoosts) {
    const trackedStat = stats.TrackedStats.find((x) => x.definition.id === parseInt(boostID));
    if (!trackedStat) continue;

    const currentBoost = playerBoosts[boostID];

    // check if this is the first iteration
    if (!trackedStat.lastCheck || !stats.StartTime) {
      trackedStat.start = currentBoost;
      trackedStat.current = currentBoost;
      trackedStat.gained = 0;
    } else {
      // first calculate the difference, then set the new value for next iteration
      const gained = currentBoost - trackedStat.current;
      trackedStat.current = currentBoost;

      // before updating the tracked gains, see if we gained this iteration and log it
      if (gained > 0) {
        trackedStat.gained += gained;
        stats.TotalStats += gained;

        // prepare an entry for the stat log that shows individual stat drops
        const statLogEntry = {
          name: trackedStat.definition.name,
          timestamp: new Date().toLocaleTimeString("en-US", {
            hour: "2-digit",
            minute: "2-digit",
            second: "2-digit",
            hour12: false,
          }),
        };

        // prepend this
        statLog.unshift(statLogEntry);

        // remove the oldest entries, keeping the log to a maximum of 150 entries
        if (statLog.length > 150) {
          statLog.length = 150;
        }
      }
    }

    trackedStat.lastCheck = new Date();
  }

  // this might be the first time we're tracking, or it has been reset manually by the player,
  // so start tracking from this point onwards
  if (!stats.StartTime) stats.StartTime = new Date();

  // save to history
  localStorage.setItem("elnaeth-stats-history", JSON.stringify(stats));

  /*
  // loop over all stats rows
  for (const div of statsDivs) {
    // loop over all stat names to find if the current row matches one of the stats we're looking for
    for (const stat of statNames) {
      // for the first iteration all stats will be at 0, we take the start on page refresh
      if (stats[stat].start === 0 && stats[stat].start === stats[stat].current) {
        stats[stat].start = baseValue;
        stats[stat].current = baseValue;
        stats[stat].gained = 0;
      } else {
        // we have already found the start value, now keep track of the current value
        stats[stat].current = baseValue;

        // finally, we can see if the start value is no longer the current base value, so calculate gains
        if (stats[stat].start !== baseValue) {
          stats[stat].gained = stats[stat].current - stats[stat].start;

          // add it to the total gains
          if (!stats[stat].mastery) stats.Total += stats[stat].gained;

          // keep track of the stat in the log
          const statLogEntry = {
            name: stat,
            newCurrent: baseValue,
            timestamp: new Date().toLocaleTimeString("en-US", {
              hour: "2-digit",
              minute: "2-digit",
              second: "2-digit",
              hour12: false,
            }),
          };

          // try to find if this entry already exists in the log
          const existingEntry = statLog.find((entry) => entry.name === stat && entry.newCurrent === baseValue);
          if (!existingEntry) {
            // prepend the new stat entry to the log
            statLog.unshift(statLogEntry);
          }

          // remove the oldest entry, keeping the log to a maximum of 100 entries
          if (statLog.length > 100) {
            statLog.length = 100;
          }
        }
      }
    }
  }
*/

  return stats;
};

// we track a select set of items
const trackedItems = [
  ItemTypes.MANA_DUST,
  ItemTypes.CODEX,
  ItemTypes.ELEMENTAL_SHARDS,

  ItemTypes.WOOD,
  ItemTypes.FISH,
  ItemTypes.IRON,

  ItemTypes.TOME_OF_FIRE,
  ItemTypes.TOME_OF_WATER,
  ItemTypes.TOME_OF_NATURE,

  ItemTypes.TOME_OF_MANA_SHIELD,

  ItemTypes.ORB_OF_CHAOS,
  ItemTypes.ORB_OF_POWER,
  ItemTypes.ORB_OF_DIVINITY,

  ItemTypes.ENCHANT_FIRE_RESISTANCE,
  ItemTypes.ENCHANT_WATER_RESISTANCE,
  ItemTypes.ENCHANT_NATURE_RESISTANCE,
  ItemTypes.ENCHANT_INFERNO,
  ItemTypes.ENCHANT_TIDAL_WRATH,
  ItemTypes.ENCHANT_WILDHEART,
  ItemTypes.ENCHANT_INSIGHT,
  ItemTypes.ENCHANT_BOUNTIFUL_HARVEST,
  ItemTypes.ENCHANT_PROSPERITY,
  ItemTypes.ENCHANT_FORTUNE,
  ItemTypes.ENCHANT_GROWTH,
  ItemTypes.ENCHANT_VITALITY,

  ItemTypes.IRONBARK,
  ItemTypes.ASBESTOS,
  ItemTypes.FISH_SCALES,

  ItemTypes.WATER_ESSENCE,
  ItemTypes.NATURE_ESSENCE,
  ItemTypes.FIRE_ESSENCE,

  ItemTypes.REAGENT_ELDERWOOD,
  ItemTypes.REAGENT_LODESTONE,
  ItemTypes.REAGENT_WHITE_PEARL,
  ItemTypes.REAGENT_FOUR_LEAF_CLOVER,
  ItemTypes.REAGENT_ENCHANTED_DROPLET,
  ItemTypes.REAGENT_INFERNAL_HEART,

  ItemTypes.SAGEROOT,
  ItemTypes.BLOOMWELL,
  ItemTypes.SUNPETAL,
];

// keep track of loots
const lootOutput = {
  first: new Date(),
  last: new Date(),
  elapsedSeconds: 0,
  items: [],
};

// keep track of loot results for each gather or battle
const parseActionLoot = () => {
  const stored = localStorage.getItem("elnaeth-item-drops");
  if (stored) {
    const parsed = JSON.parse(stored);
    if (parsed.first) parsed.first = new Date(parsed.first);
    if (parsed.last) parsed.last = new Date(parsed.last);
    lootOutput.first = parsed.first;
    lootOutput.last = parsed.last;
    lootOutput.elapsedSeconds = parsed.elapsedSeconds;
    lootOutput.items = parsed.items || [];
  }

  // now that we know we've got either a fresh set of lootOutput, or a reloaded one from storage
  // we can set the last known date to right now, since we're tracking from start to end
  lootOutput.last = new Date();

  if (isBattling()) {
    const lastBattle = manarion.battle;
    if (!lastBattle) return lootOutput;

    parseLootResults(lastBattle.Loot);

    // TODO also track lastBattle.Items for gear drops
  }

  if (isGathering()) {
    const lastGather = manarion.gather;
    if (!lastGather) return lootOutput;

    parseLootResults(lastGather.Loot);

    // TODO also track lastGather.Items for gear drops
  }

  // calculate elapsed time
  if (lootOutput.first && lootOutput.last) {
    lootOutput.elapsedSeconds = (lootOutput.last - lootOutput.first) / 1000;
  }

  // save to localStorage for next iteration
  localStorage.setItem("elnaeth-item-drops", JSON.stringify(lootOutput));

  return lootOutput;
};

const parseLootResults = (latestLoot) => {
  if (!latestLoot) return;

  const lootIDs = Object.keys(latestLoot);
  for (const lootID of lootIDs) {
    const lootDefinition = trackedItems.find((definition) => definition.id === parseInt(lootID));
    if (!lootDefinition) continue; // this is not a tracked loot type

    // TODO perhaps we need to parseInt() this because it is a float ?
    // but we could let the number util do it instead and keep this pristine
    const amount = latestLoot[lootID];

    // find the item in the lootOutput.items
    const existingEntry = lootOutput.items.find((entry) => entry.id === lootDefinition.id);
    if (!existingEntry) {
      // create new entry with this amount
      lootOutput.items.push({
        id: lootDefinition.id,
        name: lootDefinition.name,
        rarity: lootDefinition.rarity,
        amount: amount,
      });
    } else {
      // add found amount to existing entry
      existingEntry.amount += amount;
    }
  }
};

// parse the entire content of the loot tracker for all shard entries and calculate rates
const parseShards = () => {
  const output = {
    first: null,
    last: new Date(), // the last drop of the day is always statically taken as current system time for more accuracy

    elapsedSeconds: 0,
    total: 0,
  };

  // loop through game loot tracker
  for (const lootDrop of manarion.lootTracker.entries) {
    // regardless of the loot, always set the first found loot to the earliest parsed loot
    const date = new Date(lootDrop.Timestamp * 1000);
    if (date < output.first || !output.first) {
      output.first = date;
    }

    // skip if not a shard drop
    if (lootDrop.LootID !== ItemTypes.ELEMENTAL_SHARDS.id) continue;

    output.total += lootDrop.Amount;
  }

  // calculate the time between first and last tracked shard drop
  if (output.first && output.last) {
    output.elapsedSeconds = (output.last - output.first) / 1000;
  }

  return output;
};

const getQuestTime = () => {
  if (isBattling()) {
    const battleProgress = manarion.player.BattleQuestProgress;
    const battleQuestGoal = manarion.player.BattleQuestCompleted;

    const remaining = battleQuestGoal - battleProgress;
    const seconds = remaining * 3;
    return formatTime(seconds / 60);
  }

  if (isGathering()) {
    const gatherProgress = manarion.player.GatherQuestProgress;
    const gatherQuestGoal = manarion.player.GatherQuestCompleted;

    const remaining = gatherQuestGoal - gatherProgress;
    const seconds = remaining * 3;
    return formatTime(seconds / 60);
  }

  // weird fallback but ok
  return formatTime(0);
};

// calculate time to next level
const getTNL = () => {
  if (isBattling()) {
    const current = manarion.player.Experience;
    const next = manarion.player.ExperienceToLevel;

    return {
      remaining: next - current,
      remainingPercent: (((next - current) / next) * 100).toFixed(2),
    };
  }

  if (isGathering()) {
    let current;
    let next;
    switch (manarion.player.ActionType) {
      case "mining":
        current = manarion.player.MiningExperience;
        next = manarion.player.MiningExperienceToLevel;
        break;

      case "fishing":
        current = manarion.player.FishingExperience;
        next = manarion.player.FishingExperienceToLevel;
        break;

      case "woodcutting":
        current = manarion.player.WoodcuttingExperience;
        next = manarion.player.WoodcuttingExperienceToLevel;
        break;
    }

    return {
      remaining: next - current,
      remainingPercent: (((next - current) / next) * 100).toFixed(2),
    };
  }
};

const trackLastBattleGains = () => {
  lastXP = 0;
  lastDust = 0;
  lastResource = 0;
  if (!manarion.battle) return;
  const lastBattle = manarion.battle;

  lastXP = lastBattle.ExperienceGained ? parseInt(lastBattle.ExperienceGained) : 0;
  lastDust = lastBattle.Loot ? parseInt(lastBattle.Loot[ItemTypes.MANA_DUST.id]) : 0;
};

const trackLastGatheringGains = () => {
  lastXP = 0;
  lastResource = 0;
  lastDust = 0;

  if (!manarion.gather) return;
  const lastGather = manarion.gather;

  lastXP = lastGather.ExperienceGained ? parseFloat(lastGather.ExperienceGained) : 0;

  switch (manarion.player.ActionType) {
    case "mining":
      lastResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.IRON.id]) : 0;
      break;

    case "fishing":
      lastResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.FISH.id]) : 0;
      break;

    case "woodcutting":
      lastResource = lastGather.Loot ? parseFloat(lastGather.Loot[ItemTypes.WOOD.id]) : 0;
      break;
  }
};

const createMainTracker = (leftMenu) => {
  const tracker = document.createElement("div");
  tracker.innerHTML = `
    <div id="elnaeth-tracker" style="display: none;"></div>

    <hr width="100%" style="border-top: 1px solid var(--ring); margin: 2px 0 2px 0;" />

    <div class="grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-1 text-sm lg:grid-cols-2" title="Kindly provided by Elnaeth. Tips appreciated!">
      <div class="flex col-span-2 justify-between"><span>Quest Timer:</span><span id="quest-timer">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Time to Level:</span><span id="xp-tnl">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>XP / Hr:</span><span id="xp-rate">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>XP / Day:</span><span id="xp-day-rate">Calculating...</span></div>
      <div class="battle-specific flex col-span-2 justify-between"><span>Mana Dust / Hr:</span><span id="dust-rate">Calculating...</span></div>
      <div class="battle-specific flex col-span-2 justify-between"><span>Mana Dust / Day:</span><span id="dust-day-rate">Calculating...</span></div>
      <div class="gather-specific flex col-span-2 justify-between"><span>Resource / Hr:</span><span id="resource-rate">Calculating...</span></div>
      <div class="gather-specific flex col-span-2 justify-between"><span>Resource / Day:</span><span id="resource-day-rate">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Shards / Hr:</span><span id="shard-rate">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Shards / Day:</span><span id="shard-day-rate">Calculating...</span></div>
    </div>

    <hr width="100%" style="border-top: 1px solid var(--ring); margin: 2px 0 2px 0;" />

    <div class="grid grid-cols-4 gap-x-4 gap-y-1 p-2 my-1 text-sm lg:grid-cols-2" title="Kindly provided by Elnaeth. Tips appreciated!">
      <div class="flex col-span-2 justify-between"><span>Intellect gained:</span><span id="intellect-gained">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Stamina gained:</span><span id="stamina-gained">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Spirit gained:</span><span id="spirit-gained">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Focus gained:</span><span id="focus-gained">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Mana gained:</span><span id="mana-gained">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Mastery gained:</span><span id="mastery-gained">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Tracked time:</span><span id="tracked-time">Calculating...</span></div>
      <div class="flex col-span-2 justify-between"><span>Total stats:</span><span id="stats-hr">Calculating...</span></div>
      
      <div class="flex col-span-2 justify-between text-center">
        <button id="reset-stat-tracker" class="m-0 p-0 bg-red-500 text-sm text-white rounded cursor-pointer">Reset</button>
      </div>
    </div>`;

  leftMenu.insertAdjacentElement("afterend", tracker);

  document.getElementById("reset-stat-tracker").addEventListener("click", () => {
    // Reset the stat tracker
    localStorage.removeItem("elnaeth-stats-history");
    stats = cleanStats();
  });
};

const createStatsTracker = (lootTracker) => {
  // Create the stat tracker header
  const header = document.createElement("div");
  header.className = "text-center text-lg";
  header.title = "Kindly provided by Elnaeth. Tips appreciated!";

  // Create the checkbox to enable/disable the stats tracker
  const checkboxContainer = document.createElement("div");
  checkboxContainer.className = "inline-flex items-center ml-3";

  const checkboxLabel = document.createElement("label");
  checkboxLabel.setAttribute("for", "toggle-stats");
  checkboxLabel.textContent = "Stat Drop Tracker";
  checkboxContainer.appendChild(checkboxLabel);
  checkboxLabel.style.marginRight = "5px";

  // create the checkbox to enable/disable the items tracker
  const checkbox = document.createElement("input");
  // Load checkbox state from localStorage
  const savedState = localStorage.getItem("elnaeth-stats-log-visible");
  const isChecked = savedState === null ? true : savedState === "true";
  checkbox.checked = isChecked; // set the initial checked state based on localStorage
  checkbox.type = "checkbox";
  checkbox.id = "toggle-stats";
  checkbox.addEventListener("change", () => {
    // save visibility in localstorage
    localStorage.setItem("elnaeth-stats-log-visible", checkbox.checked);
    statsLogContainer.style.display = checkbox.checked ? "block" : "none";
  });
  checkboxContainer.appendChild(checkbox);

  // Append the header text and the checkbox container to the header
  header.appendChild(checkboxContainer);

  // add the header to the DOM, right before the loot tracker
  lootTracker.previousSibling.insertAdjacentElement("beforebegin", header);

  // Create the stats log container for the actual lines
  const statsLogContainer = document.createElement("div");
  statsLogContainer.id = "elnaeth-stats-log";
  statsLogContainer.className = "scrollbar-none hover:scrollbar-thin scrollbar-track-transparent h-15 max-h-15 grow-1 overflow-x-hidden overflow-y-auto";
  statsLogContainer.style.maxHeight = "200px"; // extra control for max height
  statsLogContainer.style.display = checkbox.checked ? "block" : "none"; // set initial visibility based on localStorage

  // add the stats log container to the DOM, right after the header
  header.insertAdjacentElement("afterend", statsLogContainer);
};

const createItemsTracker = (lootTracker) => {
  // Create the item drop tracker header
  const header = document.createElement("div");
  header.className = "text-center text-lg";
  header.title = "Kindly provided by Elnaeth. Tips appreciated!";

  // Create the "Stat Tracker" text and checkbox next to it
  const itemCheckboxContainer = document.createElement("div");
  itemCheckboxContainer.className = "inline-flex items-center ml-3";

  const itemCheckboxLabel = document.createElement("label");
  itemCheckboxLabel.setAttribute("for", "toggle-items");
  itemCheckboxLabel.textContent = "Item Drop Tracker";
  itemCheckboxContainer.appendChild(itemCheckboxLabel);
  itemCheckboxLabel.style.marginRight = "5px";

  // create the checkbox to enable/disable the items tracker
  const itemCheckbox = document.createElement("input");
  // Load checkbox state from localStorage
  const itemSavedState = localStorage.getItem("elnaeth-items-log-visible");
  const itemIsChecked = itemSavedState === null ? true : itemSavedState === "true";
  itemCheckbox.checked = itemIsChecked; // set the initial checked state based on localStorage
  itemCheckbox.type = "checkbox";
  itemCheckbox.id = "toggle-items";
  itemCheckbox.addEventListener("change", () => {
    // save visibility in localstorage
    localStorage.setItem("elnaeth-items-log-visible", itemCheckbox.checked);
    itemsLogContainer.style.display = itemCheckbox.checked ? "block" : "none";
  });
  itemCheckboxContainer.appendChild(itemCheckbox);

  // Append the header text and the checkbox container to the header
  header.appendChild(itemCheckboxContainer);

  const resetButton = document.createElement("button");
  resetButton.textContent = "Reset";
  resetButton.className = "m-0 p-0 bg-red-500 text-sm text-white rounded cursor-pointer";
  resetButton.addEventListener("click", () => {
    // Reset the item drops
    localStorage.removeItem("elnaeth-item-drops");
    lootOutput.first = new Date();
    lootOutput.last = new Date();
    lootOutput.elapsedSeconds = 0;
    lootOutput.items = [];
  });

  header.appendChild(document.createElement("br"));
  header.appendChild(resetButton);

  // add the header to the DOM, right before the loot tracker
  lootTracker.previousSibling.insertAdjacentElement("beforebegin", header);

  // Create the items log container
  const itemsLogContainer = document.createElement("div");
  itemsLogContainer.id = "elnaeth-items-log";
  itemsLogContainer.className = "scrollbar-none hover:scrollbar-thin scrollbar-track-transparent h-15 max-h-15 grow-1 overflow-x-hidden overflow-y-auto";
  itemsLogContainer.style.maxHeight = "200px";
  itemsLogContainer.style.display = itemCheckbox.checked ? "block" : "none";

  header.insertAdjacentElement("afterend", itemsLogContainer);
};

const createTrackerContainers = () => {
  // Look for the entire menu on the left of the screen
  const leftMenu = document.querySelector("div.grid.grid-cols-4");
  if (!leftMenu) return;

  // find the native game UI loot tracker
  const lootTracker = document.querySelector("div.scrollbar-none.scrollbar-track-transparent.h-60.grow-1.overflow-x-hidden.overflow-y-auto");
  if (!lootTracker) return;

  // add the main tracker container on the left menu
  let exists = document.getElementById("elnaeth-tracker");
  if (!exists) {
    createMainTracker(leftMenu);
  }

  // create our stats tracker window above the loot tracker
  exists = document.getElementById("elnaeth-stats-log");
  if (!exists) {
    createStatsTracker(lootTracker);
  }

  // create our items tracker window above the loot tracker
  exists = document.getElementById("elnaeth-items-log");
  if (!exists) {
    createItemsTracker(lootTracker);
    document.getElementById("elnaeth-items-log").addEventListener("click", function (event) {
      if (event.target.matches("i.elnaeth-delete-row")) {
        const itemId = parseInt(event.target.dataset.itemId);
        const itemDrops = JSON.parse(localStorage.getItem("elnaeth-item-drops"));
        itemDrops.items = itemDrops.items.filter((i) => i.id !== itemId);
        localStorage.setItem("elnaeth-item-drops", JSON.stringify(itemDrops));
      }
    });
  }
};

const updateTracker = () => {
  // create the tracker HTML elements
  createTrackerContainers();

  // show/hide battler specific stats depending on if we're battling
  const battleContainers = document.getElementsByClassName("battle-specific");
  for (let container of battleContainers) {
    container.style.display = isBattling() ? "flex" : "none";
  }

  // show/hide gatherer specific stats depending on if we're gathering
  const gatherContainers = document.getElementsByClassName("gather-specific");
  for (let container of gatherContainers) {
    container.style.display = isGathering() ? "flex" : "none";
  }

  // keep track of stats
  const stats = getStats();
  const logTracker = document.getElementById("elnaeth-stats-log");
  if (logTracker) {
    // clear the log tracker and re-render
    logTracker.innerHTML = "";

    statLog.forEach((stat) => {
      const statDiv = document.createElement("div");
      statDiv.innerHTML = `
        <div class="max-w-full overflow-hidden align-middle text-sm text-ellipsis whitespace-nowrap">
             <span class="text-foreground/50 text-xs"> ${stat.timestamp} </span>
             <span class="rarity-uncommon"> +1 ${stat.name} </span>
        </div>`;

      logTracker.appendChild(statDiv);
    });
  }

  // keep track of gains, like exp and dust, or incoming resources
  if (isBattling()) trackLastBattleGains();
  if (isGathering()) trackLastGatheringGains();

  // Calculate rates
  const xpPerHr = lastXP ? lastXP * 1200 : 0;
  const xpPerDay = xpPerHr * 24;

  const dustPerHr = lastDust ? lastDust * 1200 : 0;
  const dustPerDay = dustPerHr * 24;

  const resourcePerHr = lastResource ? lastResource * 1200 : 0;
  const resourcePerDay = resourcePerHr * 24;

  // Calculate time to next level
  const tnl = getTNL();
  const minutesToLevel = xpPerHr > 0 && tnl ? (tnl.remaining / xpPerHr) * 60 : null;

  // Calculate quest completion time
  const questTime = getQuestTime();

  // Calculate percentage to next level
  let percentageToLevel = "N/A";
  if (tnl && xpPerHr > 0) {
    percentageToLevel = tnl.remainingPercent + "%";
  }

  // update level calculation and quest timer
  document.getElementById("xp-tnl").textContent = minutesToLevel ? `${formatTime(minutesToLevel)} (${percentageToLevel})` : "Calculating...";
  document.getElementById("quest-timer").textContent = questTime ?? "N/A";

  // update xp, dust and resource rates
  document.getElementById("xp-rate").textContent = xpPerHr ? formatNumber(xpPerHr) : "Calculating...";
  document.getElementById("xp-day-rate").textContent = xpPerDay ? formatNumber(xpPerDay) : "Calculating...";

  document.getElementById("dust-rate").textContent = dustPerHr ? formatNumber(dustPerHr) : "Calculating...";
  document.getElementById("dust-day-rate").textContent = dustPerDay ? formatNumber(dustPerDay) : "Calculating...";

  document.getElementById("resource-rate").textContent = resourcePerHr ? formatNumber(resourcePerHr) : "Calculating...";
  document.getElementById("resource-day-rate").textContent = resourcePerDay ? formatNumber(resourcePerDay) : "Calculating...";

  // calculate stats per hour
  if (ticks > 0) {
    const secondsPassed = (stats.EndTime - stats.StartTime) / 1000;
    const hoursPassed = secondsPassed / 3600;
    const statsPerHour = stats.TotalStats / hoursPassed;
    const tookTime = formatTime(secondsPassed / 60);

    // show stats per hour and total
    document.getElementById("stats-hr").textContent = stats.TotalStats + " (" + statsPerHour.toFixed(2) + " / hr)";
    document.getElementById("tracked-time").textContent = tookTime;

    // show shards per hour and total if we can
    const shards = parseShards();
    if (shards.elapsedSeconds > 0) {
      const shardsPerHour = shards.total / (shards.elapsedSeconds / 3600);
      const shardsPerDay = shards.total / (shards.elapsedSeconds / 86400);
      document.getElementById("shard-rate").textContent = formatNumber(shardsPerHour);
      document.getElementById("shard-day-rate").textContent = formatNumber(shardsPerDay);
    }
  }

  // write out stat gains
  document.getElementById("intellect-gained").textContent = getStatByName("intellect").gained;
  document.getElementById("stamina-gained").textContent = getStatByName("stamina").gained;
  document.getElementById("spirit-gained").textContent = getStatByName("spirit").gained;
  document.getElementById("focus-gained").textContent = getStatByName("focus").gained;
  document.getElementById("mana-gained").textContent = getStatByName("mana").gained;

  // write out current mastery gains
  if (getStatByName("fire").active) document.getElementById("mastery-gained").textContent = getStatByName("fire").gained / 100 + "%";
  if (getStatByName("water").active) document.getElementById("mastery-gained").textContent = getStatByName("water").gained / 100 + "%";
  if (getStatByName("nature").active) document.getElementById("mastery-gained").textContent = getStatByName("nature").gained / 100 + "%";

  // parse the latest action for any loot drops
  const itemDrops = parseActionLoot();

  // Render item drops in the item tracker container
  const itemsLog = document.getElementById("elnaeth-items-log");
  if (itemsLog) {
    itemsLog.innerHTML = "";

    // Calculate elapsed time for per-hour rate
    const elapsedHours = itemDrops.elapsedSeconds > 0 ? itemDrops.elapsedSeconds / 3600 : 1;

    // render the items
    const sortedByRarity = Array.from(itemDrops.items).sort((a, b) => sortByRarity(a, b));
    sortedByRarity.forEach((item) => {
      const perHour = (item.amount / elapsedHours).toFixed(2);
      const itemDiv = document.createElement("div");
      itemDiv.innerHTML = `
          <div class="max-w-full overflow-hidden align-middle text-sm text-ellipsis whitespace-nowrap">
            <i data-item-id="${item.id}" class="elnaeth-delete-row cursor-pointer text-xs">[-]</i>

            <span class="rarity-${item.rarity}">${item.name}:</span> <span>${formatNumber(item.amount)}</span>
            <span class="text-foreground/50 text-xs">(${formatNumber(perHour)}/HR)</span>
          </div>`;

      itemsLog.appendChild(itemDiv);
    });
  }

  ticks++;
};

const condenseMainMenu = () => {
  document.querySelectorAll("div.grid.grid-cols-4").forEach((grid) => {
    if (grid.classList.contains("gap-y-2")) {
      grid.classList.remove("gap-y-2");
    }
  });
};

  // start tracking everything when the DOM is done
  setTimeout(() => {
    // injects our containers and pulls fresh data
    updateTracker();

    // make the left menu not use as much vertical spacing
    condenseMainMenu();
  }, 250);

  // Update the tracker every 3 seconds
  setInterval(updateTracker, 3000);
})();