Greasy Fork is available in English.
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);
}
})();