// ==UserScript==
// @name Torn Properties Manager
// @namespace http://tampermonkey.net/
// @version 3.4
// @description Adds a property management dashboard to Torn's properties page with expiration tracking, offer status, and pagination
// @author beans_ [174079]
// @match https://www.torn.com/properties.php*
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// Constants for styling and configuration
const STYLES = {
container: 'margin: 20px; background: #2d2d2d; padding: 15px; border-radius: 5px;',
button: 'background: #444; color: #fff; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;',
tableCell: 'padding: 8px; border-bottom: 1px solid #444; color: #fff;',
statusColors: {
offered: 'rgba(0, 255, 0, 0.1)',
expired: 'rgba(255, 0, 0, 0.1)',
warning: 'rgba(255, 165, 0, 0.1)',
hover: 'rgba(255, 255, 255, 0.1)'
},
stats: {
section: 'margin-top: 15px; text-align: center;',
toggleButton: 'background: #444; color: #fff; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;',
content: 'margin-top: 10px; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 4px; text-align: left;',
flexContainer: `
display: flex;
gap: 20px;
@media (max-width: 768px) {
flex-direction: column;
}
`.replace(/\s+/g, ' ').trim(),
column: 'flex: 1;',
divider: `
width: 1px;
background: #444;
@media (max-width: 768px) {
width: 100%;
height: 1px;
margin: 10px 0;
}
`.replace(/\s+/g, ' ').trim(),
heading: 'color: #fff; margin: 0 0 15px 0;',
subheading: 'color: #888; font-size: 0.8em; margin: -10px 0 15px 0;',
grid: 'display: grid; gap: 10px;',
card: 'background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px; transition: background 0.2s;',
cardLabel: 'font-size: 0.9em; color: #888; margin-bottom: 5px;',
cardValue: 'font-size: 1.2em; color: #fff; font-weight: 500;',
input: {
container: 'display: flex;',
field: 'flex: 1; padding: 8px; background: #444; color: #fff; border: 1px solid #666; border-radius: 4px; font-size: 1.1em;',
fieldLeft: 'border-radius: 4px 0 0 4px; border-right: none;',
fieldFull: 'width: calc(100% - 18px);',
button: 'background: #444; color: #fff; border: 1px solid #666; border-radius: 0 4px 4px 0; padding: 8px 12px; text-decoration: none; display: flex; align-items: center;'
},
calculateButton: 'background: #444; color: #fff; border: 1px solid #666; padding: 10px; border-radius: 4px; cursor: pointer; width: 100%; font-size: 1.1em; transition: background 0.2s;',
results: {
container: 'display: none; background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px;',
grid: 'display: grid; gap: 12px; grid-template-columns: repeat(2, 1fr);'
}
},
mobileTable: `
@media screen and (max-width: 768px) {
table, thead, tbody, tr, th, td {
display: block;
}
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr {
margin-bottom: 15px;
background: rgba(0,0,0,0.2);
border-radius: 5px;
padding: 10px;
}
td {
position: relative;
padding-left: 50% !important;
border-bottom: none !important;
}
/* Special handling for property name, status, and days left */
td:nth-of-type(1),
td:nth-of-type(2),
td:nth-of-type(3) {
display: inline-block;
padding-left: 0 !important;
width: auto;
}
td:nth-of-type(1)::after {
content: " - ";
margin: 0 5px;
}
td:nth-of-type(2)::after {
content: " - ";
margin: 0 5px;
}
td:nth-of-type(3) {
padding-left: 0 !important;
}
td:nth-of-type(3)::after {
content: " days left";
}
/* Daily Rent styling */
td:nth-of-type(4) {
margin: 10px 0;
padding-left: 0 !important;
}
td:nth-of-type(4)::before {
content: "Daily Rent: $";
position: static;
width: auto;
}
/* Create a flex container for buttons */
tr {
position: relative;
}
/* Button row styling */
td:nth-of-type(5),
td:nth-of-type(6) {
display: inline-block;
width: calc(50% - 2.5px);
padding: 0 !important;
margin: 10px 0 0 0;
vertical-align: top;
}
td:nth-of-type(5) {
margin-right: 2.5px;
}
td:nth-of-type(6) {
margin-left: 2.5px;
}
/* Button styling */
td:nth-of-type(5) a,
td:nth-of-type(6) button {
width: 100%;
padding: 10px !important;
text-align: center;
font-size: 1.1em;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
border-radius: 3px;
min-height: 37px; /* Set explicit height to match */
line-height: 1.2;
}
/* Adjust filter section */
.filter-section {
flex-direction: column;
gap: 10px;
}
.filter-section > div {
width: 100% !important;
max-width: 100% !important;
}
/* Adjust pagination */
.page-info-row {
margin: 15px 0;
}
}
`
};
const CONFIG = {
ITEMS_PER_PAGE: 15,
REFRESH_COOLDOWN: 60000, // 1 minute in milliseconds
MAX_RETRIES: 30,
RETRY_DELAY: 100,
API_ENDPOINT: 'https://api.torn.com/v2'
};
// Cache DOM queries
const getElement = (selector) => document.querySelector(selector);
const createElement = (html) => {
const div = document.createElement('div');
div.innerHTML = html.trim();
return div.firstChild;
};
/**
* Handles API requests with error handling and rate limiting
* @param {string} endpoint - API endpoint
* @param {Object} options - Request options
* @returns {Promise} API response
*/
async function fetchAPI(endpoint, options = {}) {
const apiKey = localStorage.getItem('tornApiKey');
if (!apiKey) throw new Error('No API key found');
try {
const response = await fetch(`${CONFIG.API_ENDPOINT}/${endpoint}?key=${apiKey}${options.params || ''}`);
const data = await response.json();
if (data.error) {
throw new Error(`API Error: ${data.error.error}`);
}
return data;
} catch (error) {
handleApiError(error);
throw error;
}
}
/**
* Debounces a function
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in milliseconds
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Optimize property data processing
function processPropertyData(properties) {
return Object.entries(properties)
.filter(([id, prop]) =>
prop.status !== "Owned by their spouse" &&
id !== localStorage.getItem('currentPropertyId')
)
.map(([id, prop]) => ({
propertyId: id,
name: prop.property,
status: prop.rented ? "Rented" : "Available",
daysLeft: prop.rented ? prop.rented.days_left : 0,
renew: `https://www.torn.com/properties.php#/p=options&ID=${id}&tab=${prop.rented ? 'offerExtension' : 'lease'}`,
offerMade: localStorage.getItem(`property_offer_${id}`) !== null,
costPerDay: prop.rented ? prop.rented.cost_per_day : 0,
buttonValue: prop.rented ? "Renew" : "Lease",
rentedBy: prop.rented ? prop.rented.user_id : null
}))
.sort((a, b) => a.daysLeft - b.daysLeft);
}
// Optimize table updates with DocumentFragment
function updateTableRows(tbody, properties) {
const fragment = document.createDocumentFragment();
properties.forEach(prop => {
const row = document.createElement('tr');
const baseColor = getPropertyRowColor(prop);
row.style.cssText = `transition: background-color 0.2s ease; cursor: pointer; background-color: ${baseColor};`;
const handleHover = (isHover) => {
row.style.backgroundColor = isHover ? STYLES.statusColors.hover : baseColor;
};
row.addEventListener('mouseenter', () => handleHover(true));
row.addEventListener('mouseleave', () => handleHover(false));
row.innerHTML = `
<td style="${STYLES.tableCell}">${prop.propertyId}</td>
<td style="${STYLES.tableCell}">${prop.name}</td>
<td style="${STYLES.tableCell}">${prop.offerMade ? 'Offered' : prop.status}</td>
<td style="${STYLES.tableCell}">${prop.daysLeft}</td>
<td style="${STYLES.tableCell}">
<a href="${prop.renew}" target="_blank" style="${STYLES.button}; text-decoration: none;">${prop.buttonValue}</a>
</td>
`;
fragment.appendChild(row);
});
tbody.innerHTML = '';
tbody.appendChild(fragment);
}
// Optimize observers with weak references
function setupNavigationObserver() {
// Create a more specific observer for the properties content
const contentObserver = new MutationObserver(
debounce((mutations) => {
const propertiesContainer = document.querySelector('.properties-container');
const propertiesPageWrap = document.querySelector('#properties-page-wrap');
// If our container is gone but we're still on the properties page, recreate it
if (!propertiesContainer && propertiesPageWrap) {
createPropertiesTable();
getCurrentPropertyId();
observeOfferSubmissions();
}
}, 100)
);
// Start observing the body for React navigation changes
contentObserver.observe(document.body, {
childList: true,
subtree: true
});
// Also watch for URL hash changes which might indicate React navigation
window.addEventListener('hashchange', () => {
setTimeout(() => {
const propertiesContainer = document.querySelector('.properties-container');
const propertiesPageWrap = document.querySelector('#properties-page-wrap');
if (!propertiesContainer && propertiesPageWrap) {
createPropertiesTable();
getCurrentPropertyId();
observeOfferSubmissions();
}
}, 100); // Small delay to let React render
});
}
/**
* Creates a styled button element
* @param {string} text - Button text
* @param {string} id - Button ID
* @returns {string} HTML button string
*/
function createButton(text, id) {
return `<button id="${id}" style="${STYLES.button}">${text}</button>`;
}
/**
* Determines the background color for a property row
* @param {Object} property - Property data
* @returns {string} CSS background-color value
*/
function getPropertyRowColor(property) {
if (property.offerMade) return STYLES.statusColors.offered;
if (property.daysLeft === 0) return STYLES.statusColors.expired;
if (property.daysLeft <= 10) return STYLES.statusColors.warning;
return '';
}
function createApiKeyForm(isIncorrectKey = false) {
return `
<div class="properties-container" style="${STYLES.container}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="color: #fff; margin: 0;">Properties Manager</h2>
</div>
<div style="text-align: center;">
${isIncorrectKey ?
`<p style="color: #ff6666; margin-bottom: 15px;">Incorrect API Key detected. Please enter a new one:</p>` :
`<p style="color: #fff; margin-bottom: 15px;">Please enter your Torn API key to continue:</p>`
}
<input type="text" id="torn-api-key" style="padding: 5px; margin-right: 10px; background: #444; color: #fff; border: 1px solid #666; border-radius: 3px;">
<button id="submit-api-key" style="${STYLES.button}">Submit</button>
</div>
</div>`;
}
function createPropertiesTable() {
// Remove any existing containers that might be stale
const existingContainers = document.querySelectorAll('.properties-container');
existingContainers.forEach(container => container.remove());
// Check for API key first
const apiKey = localStorage.getItem('tornApiKey');
const targetElement = document.querySelector('#properties-page-wrap');
// Wait for target element to exist with a maximum number of retries
if (!targetElement) {
if (!window.propertiesRetryCount) {
window.propertiesRetryCount = 0;
}
if (window.propertiesRetryCount < 30) { // Try for up to 3 seconds (30 * 100ms)
window.propertiesRetryCount++;
setTimeout(createPropertiesTable, 100);
} else {
console.error('Properties Manager: Failed to find #properties-page-wrap after 30 attempts');
window.propertiesRetryCount = 0;
}
return;
}
// Reset retry count on success
window.propertiesRetryCount = 0;
if (!apiKey && targetElement) {
targetElement.insertAdjacentHTML('afterbegin', createApiKeyForm());
// Add API key submission handler
document.getElementById('submit-api-key').addEventListener('click', function() {
const apiKeyInput = document.getElementById('torn-api-key');
if (apiKeyInput.value) {
localStorage.setItem('tornApiKey', apiKeyInput.value);
document.querySelector('.properties-container').remove();
createPropertiesTable();
}
});
return;
}
const tableHTML = `
<style>${STYLES.mobileTable}</style>
<div class="properties-container" style="${STYLES.container}">
<div class="properties-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="color: #fff; margin: 0; cursor: pointer;">Properties Manager</h2>
<div style="display: flex; align-items: center; gap: 10px;">
<span class="collapse-icon" style="color: #fff; font-size: 20px; cursor: pointer;">▶</span>
</div>
</div>
<div class="properties-content" style="display: none;">
<div class="filter-section" style="margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 10px; flex: 0 1 auto; max-width: 200px; width: 100%;">
<input type="text"
id="player-id-search"
placeholder="Search by Player ID"
style="padding: 5px; background: #444; color: #fff; border: 1px solid #666; border-radius: 3px; width: calc(100% - 70px);">
<button id="clear-search" style="${STYLES.button}">Clear</button>
</div>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<label style="color: #fff; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="hide-offered" style="cursor: pointer;">
Hide Offered
</label>
<label style="color: #fff; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="hide-available" style="cursor: pointer;">
Hide Available
</label>
<button id="refresh-properties" style="${STYLES.button}">Refresh</button>
</div>
</div>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; color: #fff;">
<thead>
<tr>
<th style="display: none;">Property ID</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #444; font-weight: bold;">Property Name</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #444; font-weight: bold;">Status</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #444; font-weight: bold;">Days Left</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #444; font-weight: bold;">Daily Rent</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #444; font-weight: bold;">Renew</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #444; font-weight: bold;">Offer</th>
</tr>
</thead>
<tbody id="properties-table-body">
</tbody>
</table>
</div>
<div class="page-info-row" style="text-align: center; margin: 10px 0;">
<span id="page-info" style="color: #fff; display: inline-block; padding: 5px 10px; background: rgba(0,0,0,0.2); border-radius: 4px;">Page 1</span>
</div>
<div class="pagination" style="margin-top: 15px; display: flex; justify-content: center; gap: 10px; width: 100%; max-width: 100%; overflow: hidden;">
<button id="prev-page" style="${STYLES.button}">Previous</button>
<button id="next-page" style="${STYLES.button}">Next</button>
</div>
</div>
</div>`;
if (targetElement) {
targetElement.insertAdjacentHTML('afterbegin', tableHTML);
// Add variable declarations at the top
const header = document.querySelector('.properties-header');
const content = document.querySelector('.properties-content');
const icon = document.querySelector('.collapse-icon');
const refreshButton = document.getElementById('refresh-properties');
let dataFetched = false;
let lastRefreshTime = 0;
header.addEventListener('click', () => {
const isVisible = content.style.display !== 'none';
content.style.display = isVisible ? 'none' : 'block';
icon.textContent = isVisible ? '▶' : '▼';
// Only fetch data the first time we expand
if (!isVisible && !dataFetched) {
getPropertyData();
dataFetched = true;
}
});
refreshButton.addEventListener('click', () => {
const currentTime = Date.now();
const timeSinceLastRefresh = currentTime - lastRefreshTime;
if (timeSinceLastRefresh >= 60000) { // 60000ms = 1 minute
getPropertyData();
lastRefreshTime = currentTime;
} else {
const secondsRemaining = Math.ceil((60000 - timeSinceLastRefresh) / 1000);
alert(`Please wait ${secondsRemaining} seconds before refreshing again.`);
}
});
}
}
/**
* Handles API errors and displays appropriate messages
* @param {Object} error - Error object
*/
function handleApiError(error) {
console.error('Error fetching property data:', error);
// Check if it's an incorrect API key error
if (error.message.includes('Incorrect key')) {
// Clear the invalid API key
localStorage.removeItem('tornApiKey');
// Remove existing container if present
const existingContainer = document.querySelector('.properties-container');
if (existingContainer) {
existingContainer.remove();
}
// Show the API key form
const targetElement = document.querySelector('#properties-page-wrap');
if (targetElement) {
targetElement.insertAdjacentHTML('afterbegin', createApiKeyForm(true));
// Add API key submission handler
document.getElementById('submit-api-key').addEventListener('click', function() {
const apiKeyInput = document.getElementById('torn-api-key');
if (apiKeyInput.value) {
localStorage.setItem('tornApiKey', apiKeyInput.value);
document.querySelector('.properties-container').remove();
createPropertiesTable();
}
});
}
} else {
// Handle other errors with alert
const message = error.message.includes('API Error')
? error.message
: 'Error fetching property data. Please check your API key and try again.';
alert(message);
}
}
function getPropertyData() {
const apiKey = localStorage.getItem('tornApiKey');
const currentPropertyId = localStorage.getItem('currentPropertyId');
if (!apiKey) {
// The table shouldn't exist without an API key now
return;
}
fetch(`https://api.torn.com/v2/user?key=${apiKey}&selections=properties&stat=rented&sort=ASC`)
.then(response => response.json())
.then(data => {
if (data.error) {
throw new Error(`API Error: ${data.error.error}`);
}
if (!data.properties) {
throw new Error('Invalid API response: missing properties data');
}
// Check and clean up localStorage before processing properties
if (data.properties) {
Object.entries(data.properties).forEach(([id, prop]) => {
const storedOffer = localStorage.getItem(`property_offer_${id}`);
if (storedOffer && prop.rented &&
(prop.rented.days_left > parseInt(storedOffer) || // Renewal successful
prop.rented.days_left === 0)) { // Rental expired
localStorage.removeItem(`property_offer_${id}`);
}
});
}
const properties = Object.entries(data.properties)
.filter(([id, prop]) =>
prop.status !== "Owned by their spouse" &&
id !== currentPropertyId
)
.map(([id, prop]) => ({
propertyId: id,
name: prop.property,
status: prop.rented ? "Rented" : "Available",
daysLeft: prop.rented ? prop.rented.days_left : 0,
renew: prop.rented ?`https://www.torn.com/properties.php#/p=options&ID=${id}&tab=offerExtension` : `https://www.torn.com/properties.php#/p=options&ID=${id}&tab=lease`,
offerMade: localStorage.getItem(`property_offer_${id}`) !== null,
costPerDay: prop.rented ? prop.rented.cost_per_day : 0,
buttonValue: prop.rented ? "Renew" : "Lease",
rentedBy: prop.rented ? prop.rented.user_id : null
}))
.sort((a, b) => a.daysLeft - b.daysLeft);
updateTable(properties);
// Store the days left for each property
properties.forEach(prop => {
window.propertyDaysLeft = window.propertyDaysLeft || {};
window.propertyDaysLeft[prop.propertyId] = prop.daysLeft;
});
})
.catch(handleApiError);
}
function updateTable(properties) {
const tbody = document.getElementById('properties-table-body');
const prevButton = document.getElementById('prev-page');
const nextButton = document.getElementById('next-page');
const pageInfo = document.getElementById('page-info');
if (!tbody) return;
const itemsPerPage = 15;
let currentPage = 1;
const totalPages = Math.ceil(properties.length / itemsPerPage);
// Add statistics section after pagination
const statsSection = document.querySelector('.stats-section') || createElement(`
<div class="stats-section" style="${STYLES.stats.section}">
<style>
@media (max-width: 768px) {
.stats-flex-container {
flex-direction: column !important;
}
.stats-divider {
width: 100% !important;
height: 1px !important;
margin: 10px 0 !important;
}
}
</style>
<div style="display: flex; gap: 10px; justify-content: center;">
<button class="stats-toggle" style="${STYLES.stats.toggleButton}">
Show Statistics ▼
</button>
<button class="settings-toggle" style="${STYLES.stats.toggleButton}">
Settings ⚙️
</button>
</div>
<!-- Settings Card -->
<div class="settings-content" style="display: none; ${STYLES.stats.content}; margin-bottom: 15px;">
<h3 style="${STYLES.stats.heading}">Settings</h3>
<div style="${STYLES.stats.grid}">
${createInputCard('API Key', 'api-key-input', false, localStorage.getItem('tornApiKey') || '')}
${createInputCard('Default Rental Period (days)', 'default-rental-period', false, localStorage.getItem('defaultRentalPeriod') || '30')}
${createInputCard('Default Amount ($)', 'default-rental-amount', false, localStorage.getItem('defaultRentalAmount') || '23000000')}
<button class="save-settings" style="${STYLES.stats.calculateButton}">
Save Settings 💾
</button>
</div>
</div>
<!-- Existing Stats Content -->
<div class="stats-content" style="display: none; ${STYLES.stats.content}">
<div class="stats-flex-container" style="${STYLES.stats.flexContainer}">
<!-- Revenue Stats Section -->
<div style="${STYLES.stats.column}">
<h3 style="${STYLES.stats.heading}">Revenue Stats</h3>
<div style="${STYLES.stats.subheading}">(Based on current daily rental rates)</div>
<div style="${STYLES.stats.grid}">
${createStatsCard('🏘️ Total Properties', 'total-properties')}
${createStatsCard('💰 Daily Revenue', 'daily-revenue', '$')}
${createStatsCard('📅 Monthly Revenue', 'monthly-revenue', '$')}
${createStatsCard('📈 Annual Revenue', 'annual-revenue', '$')}
</div>
</div>
<div class="stats-divider" style="${STYLES.stats.divider}"></div>
<!-- ROI Calculator Section -->
<div style="${STYLES.stats.column}">
<h3 style="${STYLES.stats.heading}">ROI Calculator</h3>
<div style="${STYLES.stats.grid}">
${createInputCard('Property Cost ($) - Default value not up to date', 'pi-cost', true, 1667000000)}
${createInputCard('Daily Rent ($)', 'daily-rent', false, Math.max(...properties.map(prop => prop.costPerDay || 0)))}
<button class="calculate-roi" style="${STYLES.stats.calculateButton}">
Calculate ROI 📊
</button>
<div class="roi-result" style="${STYLES.stats.results.container}">
<div style="${STYLES.stats.results.grid}">
${createStatsCard('⏱️ Days to ROI', 'days-to-roi')}
${createStatsCard('📅 Months to ROI', 'months-to-roi')}
${createStatsCard('📆 Years to ROI', 'years-to-roi')}
${createStatsCard('📈 Annual ROI', 'annual-roi', '', '%')}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`);
// Add stats section if it doesn't exist AND if pagination exists
const paginationElement = document.querySelector('.pagination');
if (!document.querySelector('.stats-section') && paginationElement) {
paginationElement.insertAdjacentElement('afterend', statsSection);
// Add toggle functionality
const toggleBtn = statsSection.querySelector('.stats-toggle');
const content = statsSection.querySelector('.stats-content');
if (toggleBtn && content) {
toggleBtn.addEventListener('click', () => {
const isVisible = content.style.display !== 'none';
content.style.display = isVisible ? 'none' : 'block';
toggleBtn.textContent = `${isVisible ? 'Show' : 'Hide'} Statistics ${isVisible ? '▼' : '▲'}`;
});
}
// Add calculation functionality
const calculateBtn = statsSection.querySelector('.calculate-roi');
const resultDiv = statsSection.querySelector('.roi-result');
if (calculateBtn && resultDiv) {
calculateBtn.addEventListener('click', () => {
const propertyCost = parseFloat(statsSection.querySelector('.pi-cost')?.value) || 0;
const dailyRent = parseFloat(statsSection.querySelector('.daily-rent')?.value) || 0;
if (propertyCost && dailyRent) {
const daysToRoi = Math.ceil(propertyCost / dailyRent);
const monthsToRoi = (daysToRoi / 30).toFixed(1);
const yearsToRoi = (daysToRoi / 365).toFixed(1);
// Calculate annual ROI percentage
const annualReturn = (dailyRent * 365);
const annualRoiPercentage = ((annualReturn / propertyCost) * 100).toFixed(2);
resultDiv.style.display = 'block';
resultDiv.querySelector('.days-to-roi').textContent = daysToRoi.toLocaleString();
resultDiv.querySelector('.months-to-roi').textContent = monthsToRoi;
resultDiv.querySelector('.years-to-roi').textContent = yearsToRoi;
resultDiv.querySelector('.annual-roi').textContent = annualRoiPercentage;
}
});
}
}
// Update statistics if elements exist
const totalPropertiesElement = document.querySelector('.total-properties');
const dailyRevenueElement = document.querySelector('.daily-revenue');
const monthlyRevenueElement = document.querySelector('.monthly-revenue');
const annualRevenueElement = document.querySelector('.annual-revenue');
if (totalPropertiesElement && dailyRevenueElement && monthlyRevenueElement && annualRevenueElement) {
const totalProperties = properties.length;
const dailyRevenue = properties.reduce((sum, prop) => sum + (prop.costPerDay || 0), 0);
const monthlyRevenue = dailyRevenue * 30;
const annualRevenue = dailyRevenue * 365;
totalPropertiesElement.textContent = totalProperties;
dailyRevenueElement.textContent = dailyRevenue.toLocaleString();
monthlyRevenueElement.textContent = monthlyRevenue.toLocaleString();
annualRevenueElement.textContent = annualRevenue.toLocaleString();
}
// Function to update filter labels with counts
function updateFilterLabels(properties) {
const availableCount = properties.filter(prop => prop.status === "Available").length;
const offeredCount = properties.filter(prop => prop.offerMade).length;
const hideAvailableLabel = document.getElementById('hide-available').parentElement;
const hideOfferedLabel = document.getElementById('hide-offered').parentElement;
hideAvailableLabel.innerHTML = `
<input type="checkbox" id="hide-available" style="cursor: pointer;">
Hide Available (${availableCount})
`;
hideOfferedLabel.innerHTML = `
<input type="checkbox" id="hide-offered" style="cursor: pointer;">
Hide Offered (${offeredCount})
`;
// Restore checkbox states
const newAvailableCheckbox = document.getElementById('hide-available');
const newOfferedCheckbox = document.getElementById('hide-offered');
newAvailableCheckbox.checked = localStorage.getItem('hideAvailableProperties') === 'true';
newOfferedCheckbox.checked = localStorage.getItem('hideOfferedProperties') === 'true';
// Re-attach event listeners
newAvailableCheckbox.addEventListener('change', () => {
localStorage.setItem('hideAvailableProperties', newAvailableCheckbox.checked);
currentPage = 1;
displayPage(currentPage);
});
newOfferedCheckbox.addEventListener('change', () => {
localStorage.setItem('hideOfferedProperties', newOfferedCheckbox.checked);
currentPage = 1;
displayPage(currentPage);
});
}
// Add filter functionality
const hideAvailable = document.getElementById('hide-available');
const hideOffered = document.getElementById('hide-offered');
const hideAvailableLabel = hideAvailable.parentElement;
const hideOfferedLabel = hideOffered.parentElement;
// Calculate counts
const availableCount = properties.filter(prop => prop.status === "Available").length;
const offeredCount = properties.filter(prop => prop.offerMade).length;
// Update labels with counts
hideAvailableLabel.innerHTML = `
<input type="checkbox" id="hide-available" style="cursor: pointer;">
Hide Available (${availableCount})
`;
hideOfferedLabel.innerHTML = `
<input type="checkbox" id="hide-offered" style="cursor: pointer;">
Hide Offered (${offeredCount})
`;
// Restore checkbox states
const newAvailableCheckbox = document.getElementById('hide-available');
const newOfferedCheckbox = document.getElementById('hide-offered');
newAvailableCheckbox.checked = localStorage.getItem('hideAvailableProperties') === 'true';
newOfferedCheckbox.checked = localStorage.getItem('hideOfferedProperties') === 'true';
// Attach event listeners
newAvailableCheckbox.addEventListener('change', () => {
localStorage.setItem('hideAvailableProperties', newAvailableCheckbox.checked);
currentPage = 1;
displayPage(currentPage);
});
newOfferedCheckbox.addEventListener('change', () => {
localStorage.setItem('hideOfferedProperties', newOfferedCheckbox.checked);
currentPage = 1;
displayPage(currentPage);
});
function getFilteredProperties() {
const searchId = document.getElementById('player-id-search')?.value.trim();
let filtered = [...properties]; // Create a copy of the properties array
// Get current checkbox states directly from the elements
const hideAvailable = document.getElementById('hide-available')?.checked;
const hideOffered = document.getElementById('hide-offered')?.checked;
if (hideAvailable) {
filtered = filtered.filter(prop => prop.status !== "Available");
}
if (hideOffered) {
// Explicitly check the localStorage for each property
filtered = filtered.filter(prop => {
const hasOffer = localStorage.getItem(`property_offer_${prop.propertyId}`) !== null;
return !hasOffer;
});
}
if (searchId) {
filtered = filtered.filter(prop =>
prop.rentedBy && prop.rentedBy.toString() === searchId
);
}
return filtered;
}
function displayPage(page) {
const filteredProperties = getFilteredProperties();
const totalPages = Math.ceil(filteredProperties.length / itemsPerPage);
const start = (page - 1) * itemsPerPage;
const end = Math.min(start + itemsPerPage, filteredProperties.length);
const pageProperties = filteredProperties.slice(start, end);
tbody.innerHTML = ''; // Clear existing rows
pageProperties.forEach((prop, index) => {
const row = document.createElement('tr');
const displayStatus = prop.offerMade ? 'Offered' : prop.status;
const baseColor = getPropertyRowColor(prop);
row.style.cssText = `transition: background-color 0.2s ease; cursor: pointer; background-color: ${baseColor};`;
// Add hover handlers
row.addEventListener('mouseenter', () => {
row.style.backgroundColor = STYLES.statusColors.hover;
});
row.addEventListener('mouseleave', () => {
row.style.backgroundColor = baseColor;
});
row.innerHTML = `
<td style="${STYLES.tableCell}">${prop.name}</td>
<td style="${STYLES.tableCell}">${displayStatus}</td>
<td style="${STYLES.tableCell}">${prop.daysLeft}</td>
<td style="${STYLES.tableCell}">$${prop.costPerDay.toLocaleString()}</td>
<td style="${STYLES.tableCell}">
<a href="${prop.renew}" target="_blank" style="${STYLES.button}; text-decoration: none;">${prop.buttonValue}</a>
</td>
<td style="${STYLES.tableCell}">
<button class="log-offer-btn" data-property-id="${prop.propertyId}" data-days-left="${prop.daysLeft}"
style="${STYLES.button}"
${window.innerWidth > 768 ? `title="${prop.offerMade ? 'Remove offer' : 'Log an offer'}"` : ''}>
${prop.offerMade ? '❌' : '💸'}
</button>
</td>
`;
tbody.appendChild(row);
// Add click handler for Log Offer button
const logOfferBtn = row.querySelector('.log-offer-btn');
const handleButtonClick = (e) => {
e.preventDefault();
e.stopPropagation();
const propertyId = logOfferBtn.dataset.propertyId;
const daysLeft = parseInt(logOfferBtn.dataset.daysLeft);
if (logOfferBtn.textContent.trim() === '💸') {
localStorage.setItem(`property_offer_${propertyId}`, daysLeft);
logOfferBtn.textContent = '❌';
logOfferBtn.title = 'Remove offer';
} else {
localStorage.removeItem(`property_offer_${propertyId}`);
logOfferBtn.textContent = '💸';
logOfferBtn.title = 'Log an offer';
}
// Update the property in the current dataset
const property = properties.find(p => p.propertyId === propertyId);
if (property) {
property.offerMade = !property.offerMade;
}
// Update filter labels and redisplay the page
updateFilterLabels(properties);
displayPage(currentPage);
};
// Add click event listener
logOfferBtn.addEventListener('click', handleButtonClick);
});
// Update page info
pageInfo.textContent = `Showing ${start + 1}-${end} of ${filteredProperties.length} (Page ${page} of ${totalPages})`;
prevButton.disabled = page === 1;
nextButton.disabled = page === totalPages;
prevButton.style.opacity = page === 1 ? '0.5' : '1';
nextButton.style.opacity = page === totalPages ? '0.5' : '1';
}
// Add click handlers for pagination
prevButton.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
displayPage(currentPage);
}
});
nextButton.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
displayPage(currentPage);
}
});
// Display first page
displayPage(1);
// Add search functionality
const searchInput = document.getElementById('player-id-search');
const clearButton = document.getElementById('clear-search');
if (searchInput && clearButton) {
searchInput.addEventListener('input', () => {
currentPage = 1; // Reset to first page when searching
displayPage(currentPage);
});
clearButton.addEventListener('click', () => {
searchInput.value = '';
currentPage = 1;
displayPage(currentPage);
});
}
// Add after the stats toggle event listener:
const settingsToggle = statsSection.querySelector('.settings-toggle');
const settingsContent = statsSection.querySelector('.settings-content');
const saveSettingsBtn = statsSection.querySelector('.save-settings');
if (settingsToggle && settingsContent) {
settingsToggle.addEventListener('click', () => {
const isVisible = settingsContent.style.display !== 'none';
settingsContent.style.display = isVisible ? 'none' : 'block';
settingsToggle.textContent = `Settings ${isVisible ? '⚙️' : '▼'}`;
// Hide stats content when showing settings
if (!isVisible) {
statsSection.querySelector('.stats-content').style.display = 'none';
statsSection.querySelector('.stats-toggle').textContent = 'Show Statistics ▼';
}
});
}
if (saveSettingsBtn) {
// Add event listener for delete API key button
const deleteApiKeyBtn = statsSection.querySelector('.delete-api-key');
if (deleteApiKeyBtn) {
deleteApiKeyBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to delete your API key?')) {
localStorage.removeItem('tornApiKey');
statsSection.querySelector('.api-key-input').value = '';
alert('API key deleted successfully!');
location.reload();
}
});
}
saveSettingsBtn.addEventListener('click', () => {
const apiKey = statsSection.querySelector('.api-key-input').value;
const rentalPeriod = statsSection.querySelector('.default-rental-period').value;
const defaultRentalAmount = statsSection.querySelector('.default-rental-amount').value;
if (apiKey) {
localStorage.setItem('tornApiKey', apiKey);
}
if (rentalPeriod) {
localStorage.setItem('defaultRentalPeriod', rentalPeriod);
}
if (defaultRentalAmount) {
localStorage.setItem('defaultRentalAmount', defaultRentalAmount);
}
// Show success message
alert('Settings saved successfully!');
// Refresh the page to apply new API key if it changed
if (apiKey !== "" && apiKey !== localStorage.getItem('tornApiKey')) {
location.reload();
}
});
}
// Store the days left for each property in localStorage
const propertyDaysLeft = {};
properties.forEach(prop => {
propertyDaysLeft[prop.propertyId] = prop.daysLeft;
});
localStorage.setItem('propertyDaysLeft', JSON.stringify(propertyDaysLeft));
}
function getPropertyDaysLeft(propertyId) {
// Try to get from localStorage first
const storedData = localStorage.getItem('propertyDaysLeft');
if (storedData) {
try {
const propertyData = JSON.parse(storedData);
return propertyData[propertyId] || 0;
} catch (e) {
console.error('Error parsing propertyDaysLeft data:', e);
return 0;
}
}
return 0;
}
// Add new function to observe offer submissions
function observeOfferSubmissions() {
const url = new URL(window.location.href);
if (url.hash.includes('tab=offerExtension') || url.hash.includes('tab=lease')) {
const propertyId = url.hash.match(/ID=(\d+)/)?.[1];
if (!propertyId) return;
console.log('Observing for offer submissions on property:', propertyId);
const daysLeft = getPropertyDaysLeft(propertyId);
console.log('Days left for property', propertyId, ':', daysLeft);
const observer = new MutationObserver((mutations, obs) => {
const nextButton = document.querySelector('input[type="submit"][value="NEXT"]');
const offerExtensionUl = document.querySelector('ul.offerExtension-input');
if (offerExtensionUl) {
const costLi = offerExtensionUl.querySelector('li.cost');
const amountLi = offerExtensionUl.querySelector('li.amount');
if (costLi && amountLi && !costLi.dataset.listenerAttached) {
console.log('Found form elements, setting default values...');
// Get default values from localStorage
const defaultPeriod = parseInt(localStorage.getItem('defaultRentalPeriod')) || 30;
const defaultAmount = parseInt(localStorage.getItem('defaultRentalAmount')) || 23000000;
// Only run if we haven't processed these inputs yet
if (!costLi.dataset.processed) {
setTimeout(() => {
// Set the cost inputs - use more specific selector
const costInputs = costLi.querySelectorAll('input.offerExtension.input-money:not([data-processed])');
console.log('Found cost inputs:', costInputs.length);
costInputs.forEach(input => {
if (!input.dataset.processed) {
console.log('Setting input value to:', defaultAmount);
input.value = defaultAmount;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('blur', { bubbles: true }));
input.dispatchEvent(new Event('focus', { bubbles: true }));
input.dataset.processed = 'true';
}
});
// Set the amount input - use more specific selector
const amountInput = amountLi.querySelector('input.input-money:not([data-processed])');
if (amountInput && !amountInput.dataset.processed) {
console.log('Setting amount to', defaultPeriod, 'days');
amountInput.value = defaultPeriod.toString();
amountInput.dispatchEvent(new Event('input', { bubbles: true }));
amountInput.dispatchEvent(new Event('change', { bubbles: true }));
amountInput.dispatchEvent(new Event('blur', { bubbles: true }));
amountInput.dispatchEvent(new Event('focus', { bubbles: true }));
amountInput.dataset.processed = 'true';
}
// Mark the container as processed
costLi.dataset.processed = 'true';
// Only disconnect if both inputs are processed
if (costLi.dataset.processed && amountInput?.dataset.processed) {
observer.disconnect();
}
}, 500);
}
costLi.dataset.listenerAttached = 'true';
}
}
if (nextButton && !nextButton.dataset.listenerAttached) {
console.log('Found next button');
nextButton.dataset.listenerAttached = 'true';
nextButton.addEventListener('click', () => {
console.log('Next button clicked - storing offer with days left:', daysLeft);
localStorage.setItem(`property_offer_${propertyId}`, daysLeft.toString());
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
characterData: true
});
}
}
// Add this new function after the other API-related functions
function getCurrentPropertyId() {
const apiKey = localStorage.getItem('tornApiKey');
if (!apiKey) return;
// Only fetch once per minute (60000 milliseconds)
const now = Date.now();
const lastFetched = localStorage.getItem('propertyId_lastFetched');
if (lastFetched && (now - parseInt(lastFetched) < 60000)) {
return Promise.resolve(localStorage.getItem('currentPropertyId'));
}
return fetch(`https://api.torn.com/v2/user?key=${apiKey}&selections=profile`)
.then(response => response.json())
.then(data => {
if (data.error) {
throw new Error(`API Error: ${data.error.error}`);
}
localStorage.setItem('currentPropertyId', data.property_id);
localStorage.setItem('propertyId_lastFetched', now.toString());
return data.property_id;
})
.catch(handleApiError);
}
// Initialize the script
window.addEventListener('load', function() {
let attempts = 0;
const checkForElement = setInterval(() => {
attempts++;
const targetElement = getElement('#properties-page-wrap');
if (targetElement) {
clearInterval(checkForElement);
createPropertiesTable();
getCurrentPropertyId();
observeOfferSubmissions();
setupNavigationObserver();
} else if (attempts >= CONFIG.MAX_RETRIES) {
clearInterval(checkForElement);
console.error('Properties Manager: Failed to initialize');
}
}, CONFIG.RETRY_DELAY);
});
// Listen for URL changes (for single-page app navigation)
window.addEventListener('hashchange', observeOfferSubmissions);
function createStatsCard(label, className, prefix = '', suffix = '') {
return `
<div style="${STYLES.stats.card}">
<div style="${STYLES.stats.cardLabel}">${label}</div>
<div style="${STYLES.stats.cardValue}">
${prefix}<span class="${className}">-</span>${suffix}
</div>
</div>
`;
}
function createInputCard(label, className, hasSearchButton = false, defaultValue = '') {
const inputStyle = hasSearchButton ? STYLES.stats.input.fieldLeft : STYLES.stats.input.fieldFull;
const valueAttr = defaultValue ? `value="${defaultValue}"` : '';
// Add delete button for API key
const deleteButton = className === 'api-key-input' ? `
<button class="delete-api-key"
style="${STYLES.stats.input.button}; background: #662222; margin-left: 5px;"
title="Delete API Key">
🗑️
</button>
` : '';
return `
<div style="${STYLES.stats.card}">
<div style="${STYLES.stats.cardLabel}">${label}</div>
<div style="${STYLES.stats.input.container}">
<input type="${className === 'api-key-input' ? 'text' : 'number'}" class="${className}"
style="${STYLES.stats.input.field} ${inputStyle}"
placeholder="Enter ${label.toLowerCase()}"
${valueAttr}>
${hasSearchButton ? `
<a href="https://www.torn.com/properties.php?step=sellingmarket#/property=13"
target="_blank"
style="${STYLES.stats.input.button}">🔍</a>
` : ''}
${deleteButton}
</div>
</div>
`;
}
})();