NeopetsUserscripts/scripts/stock-highlighter.user.js

147 lines
6.4 KiB
JavaScript

// ==UserScript==
// @name Stock Highlighter
// @namespace https://hollymcfarland.com
// @version 1.1
// @description Sort stocks in your portfolio and highlight ones that are ready to sell
// @author monorail
// @match https://www.neopets.com/stockmarket.phtml?type=portfolio
// @icon https://www.google.com/s2/favicons?sz=64&domain=neopets.com
// @grant none
// ==/UserScript==
(function() {
'use strict';
const DEFAULT_SELL_PRICE = 60;
const SELL_PRICE_KEY = "__monorail_userscript_sell_price"; // Unlikely to collide with anything, I think
const SELL_COLOUR = "#77FF77";
function getPairs(iter) {
/*
Given an iterable (something that can be converted to an array, anyway), return
an array of two-length arrays containing each element once, in the same order
e.g. getPairs([1, 2, 3, 4, 5, 6]) => [[1, 2], [3, 4], [5, 6]]
*/
// The function getting mapped here behaves differently depending on the parity
// of the index.
// Odd index => Empty array
// Even index => Array of array(!!) of the element at that index and the one after it
// Then because it's getting _flat_mapped, the whole thing is flattened by one level.
// Empty arrays disappear and the arrays of arrays become just two-length arrays
// https://stackoverflow.com/a/57851980/2114129
return [...iter].flatMap((_, i, a) => i % 2 ? [] : [a.slice(i, i + 2)]);
}
function getPrice(stock) {
/*
Given a row from the portfolio table representing a stock, return the current value
*/
return Number(stock.children[3].innerText);
}
function stockCompare(a, b) {
/*
Sort function for comparing stocks
*/
return getPrice(b) - getPrice(a);
}
// Get the stocks from the DOM
const portfolio = document.querySelector("#postForm > table > tbody");
const stocks = getPairs(portfolio.children);
const footer = stocks.pop()[0]; // Store footer elsewhere
stocks.shift(); // Remove first element of the array, the two header rows
stocks.sort((a, b) => (stockCompare(a[0], b[0])));
// Append all stocks to the table they're already in, moving them to the bottom
// in sorted order
for (let [stock, sellStock] of stocks) {
portfolio.appendChild(stock);
portfolio.appendChild(sellStock);
// While we're iterating over all the stocks anyway, remove the bgcolor
// attribute on them. The stocks have alternating colours and moving them
// around breaks that, so we're going to remove it entirely and reimplement
// that in a more modern way.
stock.removeAttribute("bgcolor");
}
// That last step had the side effect of moving the footer to the top
// Move that to the bottom as well
portfolio.append(footer);
// Now we add a custom stylesheet. This will do two things:
// 1. Reintroduce the alternating colour pattern that we removed earlier
// 2. Add a class to highlight stocks that are above the sell price
const stylesheet = new CSSStyleSheet();
// Every fourth row, starting with the third, is given the #EEEEFF background
// colour unless:
// - There's a "bgcolor" attribute already set.
// We removed that attribute from each stock, so this avoids recolouring the footer.
// - It has the .readyToSell class
// That's going to be set on any row that's ready to sell, where we'll need to override
// the background colour. This rule is really specific, so we can't override it easily
// with specificity, we'll just have to turn it off.
//
// It's every fourth row, not second, because the hidden rows for selling stocks still
// count in this case.
stylesheet.insertRule("#postForm > table > tbody > tr:not([bgcolor]):not(.readyToSell):nth-child(4n+3) { background-color: #EEEEFF; }");
// Any stocks with a value higher than the sell price are highlighted
stylesheet.insertRule(`.readyToSell { background-color: ${SELL_COLOUR}; }`);
document.adoptedStyleSheets = [stylesheet];
// Now that we're all set up, we can start working on highlighting stocks
function highlightStocks(sellPrice) {
for (let [stock, _] of stocks) {
// There's no reason not to add a class that already exists, or not to
// remove one that doesn't exist, that's all handled for us. So we can
// simply add or remove the class based on the price and it all works out
if (getPrice(stock) >= sellPrice) {
stock.classList.add("readyToSell");
} else {
stock.classList.remove("readyToSell");
}
}
}
// We define the function and then call it right away, rather than just doing the
// work inline, so that we can update the classes dynamically.
// We also use the customized sell price if it exists, and 60 otherwise.
highlightStocks(Number(localStorage.getItem(SELL_PRICE_KEY) ?? DEFAULT_SELL_PRICE));
// These next few blocks are all just for creating and lightly styling the input for
// setting a custom sell price.
const configContainer = document.createElement("div");
configContainer.style.margin = "auto";
configContainer.style.paddingTop = "1rem";
configContainer.style.width = "fit-content";
const sellPriceLabel = document.createElement("label");
sellPriceLabel.setAttribute("for", "sellprice");
sellPriceLabel.innerText = "Sell price: ";
sellPriceLabel.style.fontWeight = "bold";
configContainer.appendChild(sellPriceLabel);
const sellPriceInput = document.createElement("input");
sellPriceInput.setAttribute("type", "number");
sellPriceInput.setAttribute("name", "sellprice");
sellPriceInput.setAttribute("min", "0");
sellPriceInput.setAttribute("max", "9999");
sellPriceInput.value = localStorage.getItem(SELL_PRICE_KEY) ?? DEFAULT_SELL_PRICE;
configContainer.appendChild(sellPriceInput);
document.getElementById("postForm").after(configContainer);
// Now that the config input exists, we simply wire it up. Changing the value should
// update the saved value, as well as rechecking the stocks for any to highlight.
sellPriceInput.addEventListener("input", function() {
localStorage.setItem(SELL_PRICE_KEY, this.value);
highlightStocks(Number(this.value));
});
})();