Compact travel points tracker with item images, colour coding, flags, API key setup, points value estimation, dual bottleneck warnings, sorted items, Museum Day bonus, reorderable categories, and Xanax tracking.
// ==UserScript==
// @name ✈️ Points museum
// @namespace http://tampermonkey.net/
// @version 4.2.0
// @description Compact travel points tracker with item images, colour coding, flags, API key setup, points value estimation, dual bottleneck warnings, sorted items, Museum Day bonus, reorderable categories, and Xanax tracking.
// @match https://www.torn.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
/* ================= CONFIG ================= */
const PANEL_ID = 'travel_mini_hud';
const TOGGLE_ID = 'travel_mini_toggle';
const API_PANEL_ID = 'travel_api_panel';
const POLL = 45000;
const PRE_PTS=25, FLO_PTS=10, PLU_PTS=10, MET_PTS=15, FOS_PTS=20;
const CATEGORY_ORDER_KEY = 'travel_category_order';
// Default category order
const DEFAULT_CATEGORY_ORDER = ['Prehistoric', 'Flowers', 'Plushies', 'Special', 'Xanax'];
// Points Market Configuration
const POINTS_ENDPOINT = 'https://api.torn.com/v2/market/pointsmarket';
let currentPointsPrice = 0;
let pointsPriceCache = { time: 0, price: 0, history: [] };
const POINTS_CACHE_DURATION = 300000; // 5 minutes
const POINTS_HISTORY_SIZE = 5; // Keep last 5 prices for averaging
// Colour coding thresholds
const PLUSHIE_THRESHOLD = 2000;
const FLOWER_THRESHOLD = 5000;
const XANAX_THRESHOLD_GREEN = 1500;
const XANAX_THRESHOLD_ORANGE = 950;
/* ================= DATA ================= */
const GROUPS = {
Prehistoric:{
pts:PRE_PTS,
items:{
"Quartz Point":{s:"Q",loc:"CA 🇨🇦",color:"#87CEEB",id:1504},
"Chalcedony Point":{s:"CH",loc:"AR 🇦🇷",color:"#9ACD32",id:1503},
"Basalt Point":{s:"B",loc:"HW 🏝️",color:"#4682B4",id:1502},
"Quartzite Point":{s:"QZ",loc:"SA 🇿🇦",color:"#5F9EA0",id:1500},
"Chert Point":{s:"CT",loc:"UK 🇬🇧",color:"#708090",id:1501},
"Obsidian Point":{s:"O",loc:"MX 🇲🇽",color:"#00008B",id:1499}
}
},
Flowers:{
pts:FLO_PTS,
items:{
"Dahlia":{s:"DH",loc:"MX 🇲🇽",color:"#FF69B4",id:260},
"Orchid":{s:"OR",loc:"HW 🏝️",color:"#DA70D6",id:264},
"African Violet":{s:"V",loc:"SA 🇿🇦",color:"#EE82EE",id:282},
"Cherry Blossom":{s:"CB",loc:"JP 🇯🇵",color:"#FFB6C1",id:277},
"Peony":{s:"P",loc:"CN 🇨🇳",color:"#FF1493",id:276},
"Ceibo Flower":{s:"CE",loc:"AR 🇦🇷",color:"#DC143C",id:271},
"Edelweiss":{s:"E",loc:"CH 🇨🇭",color:"#F0FFF0",id:272},
"Crocus":{s:"CR",loc:"CA 🇨🇦",color:"#9370DB",id:263},
"Heather":{s:"H",loc:"UK 🇬🇧",color:"#DDA0DD",id:267},
"Tribulus Omanense":{s:"T",loc:"AE 🇦🇪",color:"#FFD700",id:385},
"Banana Orchid":{s:"BO",loc:"KY 🇰🇾",color:"#FFFF00",id:617}
}
},
Plushies:{
pts:PLU_PTS,
items:{
"Sheep Plushie":{s:"SH",loc:"B.B",color:"#FFFFFF",id:186},
"Teddy Bear Plushie":{s:"TB",loc:"B.B",color:"#8B4513",id:187},
"Kitten Plushie":{s:"KT",loc:"B.B",color:"#696969",id:215},
"Jaguar Plushie":{s:"J",loc:"MX 🇲🇽",color:"#FF8C00",id:258},
"Wolverine Plushie":{s:"W",loc:"CA 🇨🇦",color:"#A0522D",id:261},
"Nessie Plushie":{s:"N",loc:"UK 🇬🇧",color:"#008080",id:266},
"Red Fox Plushie":{s:"F",loc:"UK 🇬🇧",color:"#FF4500",id:268},
"Monkey Plushie":{s:"M",loc:"AR 🇦🇷",color:"#D2691E",id:269},
"Chamois Plushie":{s:"CM",loc:"CH 🇨🇭",color:"#DEB887",id:273},
"Panda Plushie":{s:"PD",loc:"CN 🇨🇳",color:"#000000",id:274},
"Lion Plushie":{s:"L",loc:"SA 🇿🇦",color:"#FFD700",id:281},
"Camel Plushie":{s:"CA",loc:"AE 🇦🇪",color:"#F4A460",id:384},
"Stingray Plushie":{s:"SR",loc:"KY 🇰🇾",color:"#2F4F4F",id:618}
}
}
};
// Xanax special tracking
const XANAX_ITEM = {
name: "Xanax",
id: 206,
loc: "JP 🇯🇵",
color: "#98FB98",
s: "XAN"
};
// Helper function to get item image URL
function getItemImageUrl(itemId) {
return `https://www.torn.com/images/items/${itemId}/medium.png`;
}
// Get saved category order or use default
function getCategoryOrder() {
const saved = GM_getValue(CATEGORY_ORDER_KEY, DEFAULT_CATEGORY_ORDER);
// Validate that all categories exist
const validOrder = saved.filter(cat => cat === 'Prehistoric' || cat === 'Flowers' || cat === 'Plushies' || cat === 'Special' || cat === 'Xanax');
// Add any missing categories at the end
DEFAULT_CATEGORY_ORDER.forEach(cat => {
if (!validOrder.includes(cat)) {
validOrder.push(cat);
}
});
return validOrder;
}
// Save category order
function saveCategoryOrder(order) {
GM_setValue(CATEGORY_ORDER_KEY, order);
}
// Move category up in order
function moveCategoryUp(category) {
const order = getCategoryOrder();
const index = order.indexOf(category);
if (index > 0) {
[order[index-1], order[index]] = [order[index], order[index-1]];
saveCategoryOrder(order);
render(); // Re-render with new order
}
}
// Move category down in order
function moveCategoryDown(category) {
const order = getCategoryOrder();
const index = order.indexOf(category);
if (index < order.length - 1) {
[order[index], order[index+1]] = [order[index+1], order[index]];
saveCategoryOrder(order);
render(); // Re-render with new order
}
}
// Styles
GM_addStyle(`
/* API Key Panel */
#${API_PANEL_ID} {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 320px;
background: linear-gradient(145deg, rgba(20, 25, 40, 0.98), rgba(10, 15, 25, 0.97));
color: #e0f0ff;
font: 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
border: 1px solid rgba(64, 156, 255, 0.3);
border-radius: 12px;
z-index: 1000001;
box-shadow:
inset 0 0 30px rgba(64, 156, 255, 0.1),
0 10px 40px rgba(0, 0, 0, 0.7),
0 0 50px rgba(64, 156, 255, 0.2);
backdrop-filter: blur(10px);
overflow: hidden;
animation: fadeSlide 0.4s ease-out;
}
#${API_PANEL_ID} .api-header {
padding: 16px;
font-weight: 600;
background: linear-gradient(90deg, rgba(64, 156, 255, 0.2), transparent);
color: #64b4ff;
border-bottom: 1px solid rgba(64, 156, 255, 0.2);
font-size: 13px;
letter-spacing: 0.5px;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
#${API_PANEL_ID} .api-header::before {
content: '🔑';
font-size: 14px;
}
#${API_PANEL_ID} .api-content {
padding: 16px;
}
#${API_PANEL_ID} .api-input-group {
margin-bottom: 16px;
}
#${API_PANEL_ID} .api-label {
display: block;
margin-bottom: 6px;
color: #88ccff;
font-weight: 600;
font-size: 11px;
}
#${API_PANEL_ID} .api-input {
width: 100%;
padding: 10px 12px;
background: rgba(30, 40, 55, 0.8);
border: 1px solid rgba(64, 156, 255, 0.3);
border-radius: 6px;
color: #ffffff;
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
transition: all 0.2s ease;
}
#${API_PANEL_ID} .api-input:focus {
outline: none;
border-color: #64b4ff;
box-shadow: 0 0 0 2px rgba(100, 180, 255, 0.2);
background: rgba(40, 50, 65, 0.9);
}
#${API_PANEL_ID} .api-note {
background: rgba(255, 165, 0, 0.1);
border-left: 3px solid rgba(255, 165, 0, 0.6);
padding: 10px 12px;
margin: 12px 0;
border-radius: 4px;
font-size: 10.5px;
color: #ffcc88;
line-height: 1.4;
}
#${API_PANEL_ID} .api-note strong {
color: #ffaa00;
font-weight: 700;
}
#${API_PANEL_ID} .api-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
#${API_PANEL_ID} .api-button {
flex: 1;
padding: 10px;
background: linear-gradient(145deg, #2a3b52, #152438);
border: 1px solid rgba(64, 156, 255, 0.4);
border-radius: 6px;
color: #64b4ff;
font-weight: 600;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
#${API_PANEL_ID} .api-button:hover {
background: linear-gradient(145deg, #3a4b62, #253448);
border-color: rgba(100, 180, 255, 0.6);
color: #88ccff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
#${API_PANEL_ID} .api-button.primary {
background: linear-gradient(145deg, #1a5ca3, #0d3d7a);
border-color: rgba(64, 156, 255, 0.6);
color: #ffffff;
}
#${API_PANEL_ID} .api-button.primary:hover {
background: linear-gradient(145deg, #2a6cb3, #1d4d8a);
border-color: rgba(100, 180, 255, 0.8);
}
/* API Panel Backdrop */
.api-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(3px);
z-index: 1000000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Animation keyframes */
@keyframes fadeSlide {
from { opacity: 0; transform: translateX(20px) scale(0.95); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes gentlePulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
@keyframes subtleFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes highlightPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 255, 0, 0.4); }
50% { box-shadow: 0 0 0 3px rgba(0, 255, 0, 0); }
}
@keyframes warningPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.4); }
50% { box-shadow: 0 0 0 3px rgba(255, 165, 0, 0); }
}
@keyframes dangerPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.4); }
50% { box-shadow: 0 0 0 3px rgba(255, 0, 0, 0); }
}
/* Points Tooltip */
.points-tooltip {
position: relative;
cursor: help;
}
.points-tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: rgba(20, 25, 40, 0.95);
color: #88ccff;
padding: 8px 12px;
border-radius: 6px;
font-size: 10px;
white-space: pre-line;
border: 1px solid rgba(64, 156, 255, 0.4);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s;
z-index: 100000;
pointer-events: none;
min-width: 200px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
}
.points-tooltip:hover::after {
opacity: 1;
visibility: visible;
}
/* Toggle Button */
#${TOGGLE_ID} {
position: fixed;
right: 6px;
top: 70px;
width: 36px;
height: 36px;
background: linear-gradient(145deg, #1e2b3d, #0d1725);
border: 1px solid rgba(64, 156, 255, 0.3);
border-radius: 8px;
color: #409cff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1000000;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
backdrop-filter: blur(4px);
font-weight: 600;
text-shadow: 0 0 6px rgba(64, 156, 255, 0.5);
}
#${TOGGLE_ID}:hover {
background: linear-gradient(145deg, #2a3b52, #152438);
border-color: rgba(100, 180, 255, 0.5);
color: #64b4ff;
transform: scale(1.08);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.4),
0 0 20px rgba(64, 156, 255, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
#${TOGGLE_ID}:active {
transform: scale(0.95);
transition: transform 0.1s;
}
/* Panel */
#${PANEL_ID} {
position: fixed;
right: 6px;
top: 70px;
width: 0;
height: auto;
max-height: 60vh;
background: linear-gradient(145deg, rgba(15, 20, 30, 0.98), rgba(8, 12, 20, 0.97));
color: #e0f0ff;
font: 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
border: 1px solid rgba(64, 156, 255, 0.2);
border-radius: 10px 0 0 10px;
z-index: 999999;
display: flex;
flex-direction: column;
box-shadow:
inset 0 0 20px rgba(64, 156, 255, 0.05),
0 4px 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
overflow: hidden;
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease;
opacity: 0;
transform: translateX(10px);
border-right: none;
}
#${PANEL_ID}.open {
width: 250px;
opacity: 1;
transform: translateX(0);
border-right: 1px solid rgba(64, 156, 255, 0.2);
border-radius: 10px;
animation: fadeSlide 0.3s ease-out;
}
/* Grid background */
#${PANEL_ID}::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(rgba(64, 156, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(64, 156, 255, 0.03) 1px, transparent 1px);
background-size: 20px 20px;
background-position: 0 0;
opacity: 0.3;
z-index: 0;
}
/* Header */
#${PANEL_ID} .h {
padding: 8px 12px;
font-weight: 600;
background: linear-gradient(90deg, rgba(64, 156, 255, 0.15), transparent);
color: #64b4ff;
border-bottom: 1px solid rgba(64, 156, 255, 0.15);
display: flex;
align-items: center;
gap: 6px;
position: relative;
z-index: 1;
font-size: 10px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
#${PANEL_ID} .h::before {
content: '✈';
font-size: 12px;
opacity: 0.8;
animation: subtleFloat 3s ease-in-out infinite;
}
/* Summary section - main display */
#${PANEL_ID} .s {
padding: 6px 12px;
background: rgba(25, 35, 50, 0.7);
font-weight: 700;
color: #4dabff;
text-align: center;
border-bottom: 1px solid rgba(64, 156, 255, 0.1);
position: relative;
z-index: 1;
font-size: 10px;
backdrop-filter: blur(2px);
transition: all 0.2s ease;
}
#${PANEL_ID} .s:hover {
background: rgba(30, 40, 55, 0.8);
color: #64b4ff;
}
/* Museum Day bonus line */
#${PANEL_ID} .museum-bonus {
padding: 4px 12px;
background: rgba(50, 40, 20, 0.7);
font-weight: 700;
color: #FFD700;
text-align: center;
border-bottom: 1px solid rgba(255, 215, 0, 0.2);
border-top: 1px solid rgba(255, 215, 0, 0.1);
position: relative;
z-index: 1;
font-size: 9px;
backdrop-filter: blur(2px);
letter-spacing: 0.3px;
text-shadow: 0 0 5px rgba(255, 215, 0, 0.3);
}
#${PANEL_ID} .museum-bonus::before {
content: '🏛️';
margin-right: 4px;
font-size: 10px;
}
#${PANEL_ID} .museum-bonus:hover {
background: rgba(60, 50, 30, 0.8);
color: #FFD700;
}
/* Body */
#${PANEL_ID} .b {
overflow-y: auto;
overflow-x: hidden;
position: relative;
z-index: 1;
background: transparent;
flex: 1;
max-height: calc(60vh - 70px);
scrollbar-width: thin;
scrollbar-color: rgba(64, 156, 255, 0.5) rgba(25, 35, 50, 0.2);
}
#${PANEL_ID} .b::-webkit-scrollbar {
width: 4px;
}
#${PANEL_ID} .b::-webkit-scrollbar-track {
background: rgba(25, 35, 50, 0.2);
border-radius: 2px;
}
#${PANEL_ID} .b::-webkit-scrollbar-thumb {
background: rgba(64, 156, 255, 0.5);
border-radius: 2px;
border: none;
}
#${PANEL_ID} .b::-webkit-scrollbar-thumb:hover {
background: rgba(100, 180, 255, 0.7);
}
/* Warning Alerts */
#${PANEL_ID} .a {
background: rgba(255, 75, 75, 0.1);
border-left: 2px solid rgba(255, 100, 100, 0.6);
margin: 4px 8px;
padding: 4px 8px 4px 20px;
font-weight: 600;
border-radius: 4px;
color: #ff8888;
position: relative;
font-size: 9px;
line-height: 1.2;
transition: all 0.2s ease;
}
#${PANEL_ID} .a:hover {
transform: translateX(2px);
background: rgba(255, 75, 75, 0.15);
}
#${PANEL_ID} .a::before {
content: '!';
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 900;
opacity: 0.8;
}
/* Category Titles with up/down buttons */
#${PANEL_ID} .t {
padding: 5px 8px 5px 8px;
background: rgba(64, 156, 255, 0.08);
color: #88ccff;
font-weight: 600;
border-top: 1px solid rgba(64, 156, 255, 0.1);
border-bottom: 1px solid rgba(64, 156, 255, 0.05);
position: relative;
font-size: 9px;
letter-spacing: 0.3px;
text-transform: uppercase;
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: space-between;
}
#${PANEL_ID} .t .category-controls {
display: flex;
gap: 4px;
}
#${PANEL_ID} .t .category-btn {
width: 14px;
height: 14px;
background: rgba(64, 156, 255, 0.2);
border: 1px solid rgba(64, 156, 255, 0.3);
border-radius: 3px;
color: #88ccff;
font-size: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
#${PANEL_ID} .t .category-btn:hover {
background: rgba(64, 156, 255, 0.4);
border-color: rgba(64, 156, 255, 0.6);
color: #ffffff;
transform: scale(1.1);
}
#${PANEL_ID} .t .category-btn:active {
transform: scale(0.9);
}
#${PANEL_ID} .t .category-btn.up-btn::before {
content: '▲';
}
#${PANEL_ID} .t .category-btn.down-btn::before {
content: '▼';
}
/* Rows - Colour Coded Grid with Images */
#${PANEL_ID} .r {
padding: 3px 12px;
display: grid;
grid-template-columns: 30px 35px 35px auto;
gap: 6px;
align-items: center;
transition: all 0.15s ease;
position: relative;
font-size: 9.5px;
min-height: 26px;
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
}
#${PANEL_ID} .r:hover {
background: rgba(64, 156, 255, 0.05);
}
#${PANEL_ID} .r:nth-child(even) {
background: rgba(30, 40, 55, 0.1);
}
#${PANEL_ID} .r span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 1px 4px;
border-radius: 3px;
transition: all 0.2s ease;
}
/* Item image styling */
#${PANEL_ID} .r .item-image {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
padding: 2px;
}
#${PANEL_ID} .r .item-image img {
max-width: 18px;
max-height: 18px;
object-fit: contain;
}
/* Abroad items colour coding */
#${PANEL_ID} .r span:nth-child(3) {
font-family: 'Consolas', 'Monaco', monospace;
text-align: center;
border: 1px solid;
font-weight: 700;
transition: all 0.3s ease;
}
/* Green status - Above threshold */
#${PANEL_ID} .r span.status-green {
color: #00ff00 !important;
background: rgba(0, 255, 0, 0.12) !important;
border-color: rgba(0, 255, 0, 0.3) !important;
animation: highlightPulse 2s ease-in-out infinite;
text-shadow: 0 0 4px rgba(0, 255, 0, 0.5);
}
/* Orange status - Below threshold */
#${PANEL_ID} .r span.status-orange {
color: #ffa500 !important;
background: rgba(255, 165, 0, 0.12) !important;
border-color: rgba(255, 165, 0, 0.3) !important;
animation: warningPulse 2s ease-in-out infinite;
text-shadow: 0 0 4px rgba(255, 165, 0, 0.5);
}
/* Red status - Zero */
#${PANEL_ID} .r span.status-red {
color: #ff0000 !important;
background: rgba(255, 0, 0, 0.12) !important;
border-color: rgba(255, 0, 0, 0.3) !important;
animation: dangerPulse 2s ease-in-out infinite;
text-shadow: 0 0 4px rgba(255, 0, 0, 0.5);
}
/* Xanax specific status */
#${PANEL_ID} .r span.status-xan-green {
color: #00ff00 !important;
background: rgba(0, 255, 0, 0.12) !important;
border-color: rgba(0, 255, 0, 0.3) !important;
animation: highlightPulse 2s ease-in-out infinite;
text-shadow: 0 0 4px rgba(0, 255, 0, 0.5);
}
#${PANEL_ID} .r span.status-xan-orange {
color: #ffa500 !important;
background: rgba(255, 165, 0, 0.12) !important;
border-color: rgba(255, 165, 0, 0.3) !important;
animation: warningPulse 2s ease-in-out infinite;
text-shadow: 0 0 4px rgba(255, 165, 0, 0.5);
}
#${PANEL_ID} .r span.status-xan-red {
color: #ff0000 !important;
background: rgba(255, 0, 0, 0.12) !important;
border-color: rgba(255, 0, 0, 0.3) !important;
animation: dangerPulse 2s ease-in-out infinite;
text-shadow: 0 0 4px rgba(255, 0, 0, 0.5);
}
/* Normal abroad count (no status) */
#${PANEL_ID} .r span:nth-child(3):not([class*="status-"]) {
color: #ffa0a0;
background: rgba(255, 160, 160, 0.08);
border: 1px solid rgba(255, 160, 160, 0.1);
}
#${PANEL_ID} .r:hover span:nth-child(3):not([class*="status-"]) {
background: rgba(255, 160, 160, 0.12);
border-color: rgba(255, 160, 160, 0.2);
}
/* Local count - NOW SHOWING REMAINING */
#${PANEL_ID} .r span:nth-child(2) {
color: #7fff7f;
background: rgba(127, 255, 127, 0.08);
font-weight: 700;
text-align: center;
border: 1px solid rgba(127, 255, 127, 0.1);
font-family: 'Consolas', 'Monaco', monospace;
}
#${PANEL_ID} .r:hover span:nth-child(2) {
background: rgba(127, 255, 127, 0.12);
border-color: rgba(127, 255, 127, 0.2);
}
/* Location with flag */
#${PANEL_ID} .r span:nth-child(4) {
color: #88ccff;
background: rgba(136, 204, 255, 0.08);
text-align: center;
font-weight: 600;
font-size: 9px;
border: 1px solid rgba(136, 204, 255, 0.1);
padding: 1px 6px;
}
#${PANEL_ID} .r:hover span:nth-child(4) {
background: rgba(136, 204, 255, 0.12);
border-color: rgba(136, 204, 255, 0.2);
}
/* Loading state */
#${PANEL_ID} .loading {
background: linear-gradient(90deg, transparent, rgba(64, 156, 255, 0.1), transparent);
background-size: 200% 100%;
animation: gentlePulse 1.5s ease-in-out infinite;
}
/* Compact layout for special items */
#${PANEL_ID} .special-row {
grid-template-columns: 40px 35px 35px auto !important;
}
/* Xanax row styling */
#${PANEL_ID} .xanax-row {
grid-template-columns: 40px 35px 35px auto !important;
}
/* Support Footer */
#${PANEL_ID} .support-footer {
padding: 8px 12px;
background: linear-gradient(145deg, rgba(30, 40, 55, 0.8), rgba(20, 30, 45, 0.9));
border-top: 1px solid rgba(255, 215, 0, 0.3);
text-align: center;
font-size: 9px;
color: #ffd700;
margin-top: 4px;
backdrop-filter: blur(4px);
position: relative;
z-index: 2;
}
#${PANEL_ID} .support-footer a {
color: #ffd700;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 215, 0, 0.1);
border: 1px solid rgba(255, 215, 0, 0.2);
}
#${PANEL_ID} .support-footer a:hover {
background: rgba(255, 215, 0, 0.2);
border-color: rgba(255, 215, 0, 0.4);
text-shadow: 0 0 8px rgba(255, 215, 0, 0.5);
transform: translateY(-1px);
}
#${PANEL_ID} .support-footer .heart {
color: #ff6b6b;
animation: subtleFloat 2s ease-in-out infinite;
display: inline-block;
margin: 0 4px;
}
/* Responsive */
@media (max-height: 600px) {
#${PANEL_ID} {
max-height: 70vh;
}
#${PANEL_ID} .b {
max-height: calc(70vh - 70px);
}
#${PANEL_ID} .r {
padding: 2px 10px;
min-height: 22px;
gap: 4px;
}
#${PANEL_ID} .support-footer {
padding: 6px 10px;
font-size: 8.5px;
}
}
/* Smooth scroll sync */
.scrolling-smooth {
transition: top 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
/* Reset order button */
.reset-order-btn {
background: rgba(255, 215, 0, 0.15);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 3px;
color: #ffd700;
font-size: 8px;
padding: 2px 6px;
margin-left: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: none;
}
.reset-order-btn:hover {
background: rgba(255, 215, 0, 0.3);
border-color: rgba(255, 215, 0, 0.5);
}
`);
/* ================= CREATE API KEY PANEL ================= */
function createApiPanel() {
// Check if API key already exists
const existingApiKey = GM_getValue('tornAPIKey');
// Don't show panel if API key already exists
if (existingApiKey) return;
// Create backdrop
const backdrop = document.createElement('div');
backdrop.className = 'api-backdrop';
// Create API panel
const apiPanel = document.createElement('div');
apiPanel.id = API_PANEL_ID;
apiPanel.innerHTML = `
<div class="api-header">🔑 API KEY REQUIRED</div>
<div class="api-content">
<div class="api-input-group">
<label class="api-label" for="torn-api-key">Torn API Key:</label>
<input type="text"
id="torn-api-key"
class="api-input"
placeholder="Enter your limited API key..."
maxlength="16">
</div>
<div class="api-note">
<strong>⚠️ SECURITY NOTE:</strong><br>
Use a <strong>LIMITED API KEY</strong> with only <strong>DISPLAY</strong> access.<br>
This script only needs to read your displayed items - no other permissions needed.<br>
<em>Create key at: <strong>Torn.com → Settings → API</strong></em>
</div>
<div class="api-buttons">
<button class="api-button" id="save-api-key">Save API Key</button>
<button class="api-button primary" id="skip-api">Skip (Limited Mode)</button>
</div>
</div>
`;
// Add to page
document.body.appendChild(backdrop);
document.body.appendChild(apiPanel);
// Event listeners
const saveButton = apiPanel.querySelector('#save-api-key');
const skipButton = apiPanel.querySelector('#skip-api');
const apiInput = apiPanel.querySelector('#torn-api-key');
// Auto-focus input
setTimeout(() => apiInput.focus(), 100);
// Save API key
saveButton.onclick = () => {
const apiKey = apiInput.value.trim();
if (!apiKey) {
apiInput.style.borderColor = '#ff4444';
apiInput.style.boxShadow = '0 0 0 2px rgba(255, 68, 68, 0.2)';
setTimeout(() => {
apiInput.style.borderColor = '';
apiInput.style.boxShadow = '';
}, 1000);
return;
}
// Validate API key format (Torn API keys are 16 chars)
if (apiKey.length !== 16) {
alert('Invalid API key format. Torn API keys are exactly 16 characters long.');
return;
}
// Save the API key
GM_setValue('tornAPIKey', apiKey);
// Remove panel
document.body.removeChild(backdrop);
document.body.removeChild(apiPanel);
// Show success message and start tracker
showNotification('API key saved successfully! Starting tracker...');
setTimeout(() => {
initializeTracker();
}, 1000);
};
// Skip API key entry
skipButton.onclick = () => {
// Remove panel
document.body.removeChild(backdrop);
document.body.removeChild(apiPanel);
// Show message about limited functionality
showNotification('Running in limited mode. Add API key later via toggle right-click.');
// Start tracker anyway (will show API missing message)
initializeTracker();
};
// Allow Enter key to save
apiInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
saveButton.click();
}
});
// Close on backdrop click
backdrop.onclick = (e) => {
if (e.target === backdrop) {
document.body.removeChild(backdrop);
document.body.removeChild(apiPanel);
initializeTracker(); // Start without API
}
};
}
/* ================= NOTIFICATION FUNCTION ================= */
function showNotification(message) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(145deg, #2a3b52, #152438);
color: #88ccff;
padding: 12px 20px;
border-radius: 8px;
border: 1px solid rgba(64, 156, 255, 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
z-index: 1000002;
font-size: 12px;
font-weight: 600;
backdrop-filter: blur(8px);
animation: fadeSlide 0.3s ease-out;
text-align: center;
max-width: 300px;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.style.opacity = '0';
notification.style.transform = 'translateX(-50%) translateY(-20px)';
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}
}, 3000);
}
/* ================= CREATE MAIN ELEMENTS ================= */
function createMainElements() {
// Create toggle
const toggle = document.createElement('div');
toggle.id = TOGGLE_ID;
toggle.innerHTML = '✈';
toggle.title = 'Travel Tracker - Right click to manage API key';
// Add right-click for API management
toggle.addEventListener('contextmenu', (e) => {
e.preventDefault();
showApiManagementMenu();
});
// Create panel
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="h">TRAVEL TRACKER</div>
<div class="s">API KEY NEEDED</div>
<div class="museum-bonus">🏛️ MUSEUM BONUS: +0 PTS • +$0</div>
<div class="b"></div>
`;
// Add to page
document.body.appendChild(toggle);
document.body.appendChild(panel);
return { toggle, panel };
}
/* ================= API MANAGEMENT MENU ================= */
function showApiManagementMenu() {
// Remove existing menu if present
const existingMenu = document.getElementById('api-management-menu');
if (existingMenu) document.body.removeChild(existingMenu);
const menu = document.createElement('div');
menu.id = 'api-management-menu';
menu.style.cssText = `
position: fixed;
background: linear-gradient(145deg, rgba(20, 25, 40, 0.98), rgba(10, 15, 25, 0.97));
border: 1px solid rgba(64, 156, 255, 0.3);
border-radius: 8px;
padding: 8px 0;
min-width: 180px;
z-index: 1000003;
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
animation: fadeSlide 0.2s ease;
`;
const currentKey = GM_getValue('tornAPIKey');
menu.innerHTML = `
<div style="padding: 8px 12px; font-size: 11px; color: #88ccff; border-bottom: 1px solid rgba(64, 156, 255, 0.2);">
API Key Management
</div>
${currentKey ? `
<div class="menu-item" data-action="view">View Current Key</div>
<div class="menu-item" data-action="change">Change API Key</div>
<div class="menu-item" data-action="remove">Remove API Key</div>
` : `
<div class="menu-item" data-action="add">Add API Key</div>
`}
<div class="menu-item" data-action="reset-order">↺ Reset Category Order</div>
<div class="menu-item" data-action="support">❤️ Support Development</div>
<div class="menu-item" data-action="help">API Key Help</div>
`;
// Add menu item styles
GM_addStyle(`
.menu-item {
padding: 8px 12px;
font-size: 11px;
color: #c0e0ff;
cursor: pointer;
transition: all 0.2s ease;
}
.menu-item:hover {
background: rgba(64, 156, 255, 0.15);
color: #ffffff;
}
.menu-item[data-action="support"] {
color: #ffd700;
border-top: 1px solid rgba(255, 215, 0, 0.2);
margin-top: 4px;
}
.menu-item[data-action="support"]:hover {
background: rgba(255, 215, 0, 0.15);
color: #ffff00;
}
.menu-item[data-action="reset-order"] {
color: #88ccff;
border-top: 1px solid rgba(64, 156, 255, 0.2);
}
.menu-item[data-action="reset-order"]:hover {
background: rgba(64, 156, 255, 0.15);
}
`);
// Position near toggle
const toggle = document.getElementById(TOGGLE_ID);
const toggleRect = toggle.getBoundingClientRect();
menu.style.top = toggleRect.bottom + 5 + 'px';
menu.style.right = (window.innerWidth - toggleRect.right) + 'px';
document.body.appendChild(menu);
// Event delegation for menu items
menu.addEventListener('click', (e) => {
if (!e.target.classList.contains('menu-item')) return;
const action = e.target.dataset.action;
document.body.removeChild(menu);
switch(action) {
case 'view':
if (currentKey) {
alert(`Current API Key: ${currentKey}\n\nKey ends with: ...${currentKey.slice(-4)}`);
}
break;
case 'change':
case 'add':
createApiPanel();
break;
case 'remove':
if (confirm('Are you sure you want to remove the API key?\n\nThe tracker will work in limited mode.')) {
GM_setValue('tornAPIKey', '');
showNotification('API key removed. Please refresh the page.');
}
break;
case 'reset-order':
GM_setValue(CATEGORY_ORDER_KEY, DEFAULT_CATEGORY_ORDER);
showNotification('Category order reset to default');
render();
break;
case 'support':
// Open Supernova's profile for support
window.open('https://www.torn.com/profiles.php?XID=2637223', '_blank');
showNotification('Thank you for considering support! ❤️');
break;
case 'help':
alert(`🔑 API KEY SETUP GUIDE:
1. Go to Torn.com → Settings → API
2. Click "Create New Key"
3. Set PERMISSIONS:
• Select ONLY "Display" checkbox
• Uncheck ALL other permissions
4. Copy the 16-character API key
5. Paste it into the Travel Tracker
⚠️ SECURITY: Only use LIMITED keys with DISPLAY access!
This script only needs to read your displayed items.`);
break;
}
});
// Close menu when clicking elsewhere
setTimeout(() => {
const closeMenu = (e) => {
if (!menu.contains(e.target) && e.target.id !== TOGGLE_ID) {
document.body.removeChild(menu);
document.removeEventListener('click', closeMenu);
}
};
document.addEventListener('click', closeMenu);
}, 100);
}
/* ================= GLOBAL VARIABLES ================= */
let toggle, panel, sum, museumBonus, body, isPanelOpen = false;
/* ================= INITIALIZE TRACKER ================= */
function initializeTracker() {
const elements = createMainElements();
toggle = elements.toggle;
panel = elements.panel;
sum = panel.querySelector('.s');
museumBonus = panel.querySelector('.museum-bonus');
body = panel.querySelector('.b');
setupToggleFunctionality();
setupScrollSync();
// Check if we have API key and start rendering
const apiKey = GM_getValue('tornAPIKey');
if (apiKey) {
mainLoop();
} else {
// Show API missing message
sum.textContent = 'API KEY REQUIRED';
museumBonus.style.display = 'none'; // Hide museum bonus if no API
body.innerHTML = `
<div style="padding: 20px 12px; text-align: center; color: #ff8888; font-size: 10px;">
⚠️ No API key configured.<br><br>
<span style="color: #88ccff; font-size: 9px;">
Right-click the toggle button<br>
to add your API key.<br><br>
Only "Display" permission needed.
</span>
</div>
`;
}
}
/* ================= TOGGLE FUNCTIONALITY ================= */
function setupToggleFunctionality() {
toggle.onclick = () => {
isPanelOpen = !isPanelOpen;
panel.classList.toggle('open', isPanelOpen);
// Toggle animation
toggle.style.transform = isPanelOpen ?
'translateX(-244px) scale(1.05)' :
'translateX(0) scale(1)';
toggle.title = isPanelOpen ? 'Close Tracker' : 'Open Tracker';
toggle.style.color = isPanelOpen ? '#88ccff' : '#409cff';
if (isPanelOpen) {
toggle.innerHTML = '×';
} else {
toggle.innerHTML = '✈';
}
};
}
/* ================= SMOOTH SCROLL SYNC ================= */
function setupScrollSync() {
let lastScrollY = window.scrollY;
let scrollAnimationFrame;
function handleSmoothScroll() {
const currentScrollY = window.scrollY;
// Add smooth class during scroll
if (!toggle.classList.contains('scrolling-smooth')) {
toggle.classList.add('scrolling-smooth');
panel.classList.add('scrolling-smooth');
}
// Calculate new position
const newTop = 70 + (currentScrollY - lastScrollY) * 0.3;
const boundedTop = Math.max(4, Math.min(newTop, window.innerHeight - 40));
toggle.style.top = `${boundedTop}px`;
panel.style.top = `${boundedTop}px`;
lastScrollY = currentScrollY;
// Remove smooth class after settling
clearTimeout(scrollAnimationFrame);
scrollAnimationFrame = setTimeout(() => {
toggle.classList.remove('scrolling-smooth');
panel.classList.remove('scrolling-smooth');
}, 150);
}
// Optimized scroll handler
let scrollThrottle;
window.addEventListener('scroll', () => {
if (scrollThrottle) return;
scrollThrottle = requestAnimationFrame(() => {
handleSmoothScroll();
scrollThrottle = null;
});
});
// Initial position
handleSmoothScroll();
}
/* ================= FETCH FUNCTIONS ================= */
async function localItems() {
const key = GM_getValue('tornAPIKey');
if (!key) {
throw new Error('No API key configured');
}
try {
// Only request display data (not inventory) since we only need display permission
const response = await fetch(`https://api.torn.com/user/?selections=display&key=${key}`).then(r => r.json());
if (response.error) {
throw new Error(response.error.error || 'API Error');
}
const items = {};
// Only use display items since that's all we have permission for
if (response.display) {
response.display.forEach(item => {
items[item.name] = (items[item.name] || 0) + item.quantity;
});
}
return items;
} catch (error) {
console.error('Error fetching local items:', error);
throw error;
}
}
function gmJSON(url) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: r => {
try {
resolve(JSON.parse(r.responseText));
} catch {
resolve({});
}
},
onerror: () => resolve({})
});
});
}
async function abroadItems() {
const [yataData, promData] = await Promise.all([
gmJSON('https://yata.yt/api/v1/travel/export/'),
gmJSON('https://api.prombot.co.uk/api/travel')
]);
const abroadMap = {};
// Process YATA data
[yataData?.stocks, yataData].forEach(source => {
Object.values(source || {}).forEach(country => {
(country?.stocks || []).forEach(item => {
abroadMap[item.name] = (abroadMap[item.name] || 0) + (item.quantity || item.qty || 0);
});
});
});
return abroadMap;
}
/* ================= POINTS MARKET PRICE FETCHER ================= */
async function fetchPointsPrice(apiKey) {
const now = Date.now();
// Return cached price if still valid
if (pointsPriceCache.time && (now - pointsPriceCache.time) < POINTS_CACHE_DURATION) {
return pointsPriceCache.price;
}
try {
const response = await fetch(`${POINTS_ENDPOINT}?key=${apiKey}`);
const data = await response.json();
if (data.pointsmarket) {
// Get all available listings
const listings = Object.values(data.pointsmarket)
.filter(listing => listing.quantity > 0) // Only listings with points available
.map(listing => listing.cost)
.sort((a, b) => a - b); // Sort cheapest first
if (listings.length > 0) {
// Calculate average of cheapest 5 listings for stable price
const topListings = listings.slice(0, Math.min(5, listings.length));
const avgPrice = Math.round(topListings.reduce((sum, price) => sum + price, 0) / topListings.length);
// Update cache with history
pointsPriceCache.history.push(avgPrice);
if (pointsPriceCache.history.length > POINTS_HISTORY_SIZE) {
pointsPriceCache.history.shift();
}
// Calculate moving average if we have history
const stablePrice = pointsPriceCache.history.length > 0 ?
Math.round(pointsPriceCache.history.reduce((sum, price) => sum + price, 0) / pointsPriceCache.history.length) :
avgPrice;
pointsPriceCache = {
time: now,
price: stablePrice,
history: pointsPriceCache.history
};
currentPointsPrice = stablePrice;
return stablePrice;
}
}
} catch (error) {
console.error('Error fetching points price:', error);
}
return currentPointsPrice || 0;
}
/* ================= CALCULATION LOGIC ================= */
function calcSet(inventory, items) {
const values = Object.keys(items).map(key => inventory[key] || 0);
const sets = values.length ? Math.min(...values) : 0;
return { sets };
}
/* ================= UPDATED: FIND TWO LOWEST ITEMS PER CATEGORY ================= */
function lowestTwo(inventory, items, sets) {
// Create array of items with their counts AFTER sets have been subtracted
const itemCounts = Object.entries(items).map(([name, data]) => {
const count = inventory[name] || 0;
const remaining = count - sets;
return {
name,
code: data.s,
location: data.loc,
count: remaining
};
});
// Sort by remaining count (ascending - lowest first)
itemCounts.sort((a, b) => a.count - b.count);
// Take the two lowest items
const lowestItems = itemCounts.slice(0, 2);
// Filter out items that are not actually low (count < 5)
const lowItems = lowestItems.filter(item => item.count < 5 && item.count >= 0);
if (lowItems.length === 0) {
return null; // No items are low
}
// Create warning message with both items
const warningParts = lowItems.map(item => `${item.code} → ${item.location}`);
// Join with " & " if we have two items
if (warningParts.length === 2) {
return `Need ${warningParts[0]} & ${warningParts[1]}`;
} else {
return `Need ${warningParts[0]}`;
}
}
/* ================= GET SORTED ITEMS FOR DISPLAY ================= */
function getSortedItems(inventory, items, sets) {
// Create array of items with their counts AFTER sets have been subtracted
const itemCounts = Object.entries(items).map(([name, data]) => {
const count = inventory[name] || 0;
const remaining = count - sets;
return {
name,
data,
remaining
};
});
// Sort by remaining count (ascending - lowest first)
itemCounts.sort((a, b) => a.remaining - b.remaining);
return itemCounts;
}
/* ================= GET STATUS CLASS FOR ITEMS ================= */
function getStatusClass(itemName, abroadCount) {
// Xanax specific thresholds
if (itemName === "Xanax") {
if (abroadCount >= XANAX_THRESHOLD_GREEN) {
return 'status-xan-green';
} else if (abroadCount >= XANAX_THRESHOLD_ORANGE) {
return 'status-xan-orange';
} else {
return 'status-xan-red';
}
}
// Regular items
if (abroadCount === 0) {
return 'status-red';
}
// Check if it's a plushie
if (itemName.includes('Plushie')) {
if (abroadCount >= PLUSHIE_THRESHOLD) {
return 'status-green';
} else {
return 'status-orange';
}
}
// Check if it's a flower
if (itemName.includes('Dahlia') || itemName.includes('Orchid') || itemName.includes('African Violet') ||
itemName.includes('Cherry Blossom') || itemName.includes('Peony') || itemName.includes('Ceibo Flower') ||
itemName.includes('Edelweiss') || itemName.includes('Crocus') || itemName.includes('Heather') ||
itemName.includes('Tribulus Omanense') || itemName.includes('Banana Orchid')) {
if (abroadCount >= FLOWER_THRESHOLD) {
return 'status-green';
} else {
return 'status-orange';
}
}
// For other items (prehistoric points, meteorite, fossil)
return '';
}
/* ================= HEX TO RGB HELPER ================= */
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 };
}
/* ================= RENDER FUNCTION ================= */
async function render() {
try {
sum.classList.add('loading');
sum.textContent = 'UPDATING...';
const inventory = await localItems();
const abroad = await abroadItems();
let totalSets = 0;
let totalPoints = 0;
let categoryHtml = {};
let categoryWarnings = {};
// Process Prehistoric
const preHistResult = processCategory('Prehistoric', GROUPS.Prehistoric, inventory, abroad);
categoryHtml['Prehistoric'] = preHistResult.html;
categoryWarnings['Prehistoric'] = preHistResult.warning;
totalSets += preHistResult.sets;
totalPoints += preHistResult.points;
// Process Flowers
const flowersResult = processCategory('Flowers', GROUPS.Flowers, inventory, abroad);
categoryHtml['Flowers'] = flowersResult.html;
categoryWarnings['Flowers'] = flowersResult.warning;
totalSets += flowersResult.sets;
totalPoints += flowersResult.points;
// Process Plushies
const plushiesResult = processCategory('Plushies', GROUPS.Plushies, inventory, abroad);
categoryHtml['Plushies'] = plushiesResult.html;
categoryWarnings['Plushies'] = plushiesResult.warning;
totalSets += plushiesResult.sets;
totalPoints += plushiesResult.points;
// Process Special (Meteorite & Fossil)
const meteorite = inventory["Meteorite Fragment"] || 0;
const fossil = inventory["Patagonian Fossil"] || 0;
const specialPoints = (meteorite * MET_PTS) + (fossil * FOS_PTS);
totalPoints += specialPoints;
const meteorColor = "#FF4500";
const fossilColor = "#8B4513";
const meteorRgb = hexToRgb(meteorColor);
const fossilRgb = hexToRgb(fossilColor);
const meteorAbroad = abroad["Meteorite Fragment"] || 0;
const fossilAbroad = abroad["Patagonian Fossil"] || 0;
categoryHtml['Special'] = `
<div class="t">
<span>SPECIAL</span>
<span class="category-controls">
<span class="category-btn up-btn" data-category="Special" title="Move Up"></span>
<span class="category-btn down-btn" data-category="Special" title="Move Down"></span>
</span>
</div>
<div class="r special-row">
<div class="item-image">
<img src="${getItemImageUrl(1488)}" alt="METR" title="Meteorite Fragment">
</div>
<span>${meteorite}</span>
<span class="${getStatusClass('Meteorite Fragment', meteorAbroad)}">${meteorAbroad}</span>
<span>AR 🇦🇷</span>
</div>
<div class="r special-row">
<div class="item-image">
<img src="${getItemImageUrl(1487)}" alt="FOSL" title="Patagonian Fossil">
</div>
<span>${fossil}</span>
<span class="${getStatusClass('Patagonian Fossil', fossilAbroad)}">${fossilAbroad}</span>
<span>AR 🇦🇷</span>
</div>`;
// Check for low items in Special category
const meteorLow = meteorite < 5 && meteorite > 0 ? `Need METR → AR` : null;
const fossilLow = fossil < 5 && fossil > 0 ? `Need FOSL → AR` : null;
if (meteorLow || fossilLow) {
if (meteorLow && fossilLow) {
categoryWarnings['Special'] = `Need METR → AR & FOSL → AR`;
} else {
categoryWarnings['Special'] = meteorLow || fossilLow;
}
}
// Process Xanax
const xanax = inventory["Xanax"] || 0;
const xanaxAbroad = abroad["Xanax"] || 0;
categoryHtml['Xanax'] = `
<div class="t">
<span>XANAX</span>
<span class="category-controls">
<span class="category-btn up-btn" data-category="Xanax" title="Move Up"></span>
<span class="category-btn down-btn" data-category="Xanax" title="Move Down"></span>
</span>
</div>
<div class="r xanax-row">
<div class="item-image">
<img src="${getItemImageUrl(206)}" alt="XAN" title="Xanax">
</div>
<span>${xanax}</span>
<span class="${getStatusClass('Xanax', xanaxAbroad)}">${xanaxAbroad}</span>
<span>JP 🇯🇵</span>
</div>`;
// Check for low Xanax warning (below 950)
if (xanaxAbroad < XANAX_THRESHOLD_ORANGE && xanaxAbroad > 0) {
categoryWarnings['Xanax'] = `⚠️ Low Xanax stock: ${xanaxAbroad} abroad`;
} else if (xanaxAbroad === 0) {
categoryWarnings['Xanax'] = `❌ No Xanax abroad!`;
}
// Points value calculation
let pointsValue = 0;
let pointsValueFormatted = '';
const apiKey = GM_getValue('tornAPIKey');
let pointsPrice = 0;
if (apiKey && totalPoints > 0) {
pointsPrice = await fetchPointsPrice(apiKey);
if (pointsPrice > 0) {
pointsValue = totalPoints * pointsPrice;
if (pointsValue >= 1000000) {
pointsValueFormatted = `$${(pointsValue / 1000000).toFixed(1)}M`;
} else if (pointsValue >= 1000) {
pointsValueFormatted = `$${Math.round(pointsValue / 1000)}k`;
} else {
pointsValueFormatted = `$${pointsValue}`;
}
sum.classList.add('points-tooltip');
sum.setAttribute('data-tooltip',
`Total Points: ${totalPoints.toLocaleString()}\n` +
`Average Price per Point: $${pointsPrice.toLocaleString()}\n` +
`Total Value: $${pointsValue.toLocaleString()}`
);
}
}
// Museum Day bonus
if (apiKey && totalPoints > 0 && pointsPrice > 0) {
const bonusPoints = Math.round(totalPoints * 0.1);
const bonusValue = bonusPoints * pointsPrice;
let bonusPointsFormatted = bonusPoints.toLocaleString();
let bonusValueFormatted = '';
if (bonusValue >= 1000000) {
bonusValueFormatted = `$${(bonusValue / 1000000).toFixed(1)}M`;
} else if (bonusValue >= 1000) {
bonusValueFormatted = `$${Math.round(bonusValue / 1000)}k`;
} else {
bonusValueFormatted = `$${bonusValue}`;
}
museumBonus.innerHTML = `🏛️ MUSEUM BONUS: +${bonusPointsFormatted} PTS • +${bonusValueFormatted}`;
museumBonus.style.display = 'block';
museumBonus.classList.add('points-tooltip');
museumBonus.setAttribute('data-tooltip',
`10% bonus if redeemed on Museum Day (May 18)\n` +
`Extra Points: ${bonusPointsFormatted}\n` +
`Extra Value: ${bonusValueFormatted}`
);
} else {
museumBonus.style.display = 'none';
}
// Build HTML in saved order
let html = '';
const categoryOrder = getCategoryOrder();
categoryOrder.forEach(cat => {
if (categoryWarnings[cat]) {
html += `<div class="a">${categoryWarnings[cat]}</div>`;
}
if (categoryHtml[cat]) {
html += categoryHtml[cat];
}
});
html += `
<div class="support-footer">
<span class="heart">❤️</span>
<a href="https://www.torn.com/profiles.php?XID=2637223" target="_blank" title="Support Supernova's development">
Support Development
</a>
<span class="heart">❤️</span>
</div>`;
sum.classList.remove('loading');
if (pointsValueFormatted) {
sum.innerHTML = `✈ ${totalSets.toLocaleString()} SETS • ${totalPoints.toLocaleString()} PTS • ${pointsValueFormatted}`;
} else {
sum.textContent = `✈ ${totalSets.toLocaleString()} SETS • ${totalPoints.toLocaleString()} PTS`;
sum.classList.remove('points-tooltip');
sum.removeAttribute('data-tooltip');
}
body.innerHTML = html;
// Add event listeners to category buttons
document.querySelectorAll('.category-btn.up-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const category = btn.dataset.category;
moveCategoryUp(category);
});
});
document.querySelectorAll('.category-btn.down-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const category = btn.dataset.category;
moveCategoryDown(category);
});
});
body.scrollTop = 0;
} catch (error) {
sum.classList.remove('loading');
sum.textContent = 'API ERROR';
museumBonus.style.display = 'none';
body.innerHTML = `
<div style="padding: 20px 12px; text-align: center; color: #ff8888; font-size: 10px;">
⚠️ Error: ${error.message}<br><br>
<span style="color: #88ccff; font-size: 9px;">
Check your API key permissions.<br>
Only "Display" permission is needed.<br><br>
Right-click toggle to manage API key.
</span>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 215, 0, 0.2);">
<a href="https://www.torn.com/profiles.php?XID=2637223" target="_blank"
style="color: #ffd700; text-decoration: none; font-weight: 600;">
❤️ Support Development
</a>
</div>
</div>
`;
}
}
// Helper function to process a category
function processCategory(catName, group, inventory, abroad) {
const { sets } = calcSet(inventory, group.items);
let html = '';
let warning = null;
const displayName = catName === 'Prehistoric' ? 'PREHIST' :
catName === 'Flowers' ? 'FLOWERS' :
catName === 'Plushies' ? 'PLUSH' : catName;
html += `
<div class="t">
<span>${displayName}</span>
<span class="category-controls">
<span class="category-btn up-btn" data-category="${catName}" title="Move Up"></span>
<span class="category-btn down-btn" data-category="${catName}" title="Move Down"></span>
</span>
</div>`;
warning = lowestTwo(inventory, group.items, sets);
const sortedItems = getSortedItems(inventory, group.items, sets);
sortedItems.forEach(({ name, data, remaining }) => {
const abroadCount = abroad[name] || 0;
const statusClass = getStatusClass(name, abroadCount);
html += `
<div class="r">
<div class="item-image">
<img src="${getItemImageUrl(data.id)}" alt="${data.s}" title="${name}">
</div>
<span>${remaining}</span>
<span class="${statusClass}">${abroadCount}</span>
<span>${data.loc}</span>
</div>`;
});
return { sets, points: sets * group.pts, html, warning };
}
/* ================= MAIN LOOP ================= */
async function mainLoop() {
const apiKey = GM_getValue('tornAPIKey');
if (apiKey) {
fetchPointsPrice(apiKey).catch(() => {});
}
await render();
setTimeout(mainLoop, POLL);
}
/* ================= START EVERYTHING ================= */
if (!GM_getValue('tornAPIKey')) {
setTimeout(createApiPanel, 1000);
} else {
setTimeout(initializeTracker, 500);
}
})();