[PC/Laptop] šļø Complete property portfolio management suite. Real-time dashboard with color-coded alerts, tenant intelligence with permanent history, ROI tracking across portfolio/property types, market analysis with IQR filtering, smart lease forms with auto-fill & live calculator, notes system, export/import, auto-rotating storage. Join investors managing millions in property value!
// ==UserScript==
// @name Torn Property Manager TPM
// @version TPM.V3.0.0
// @description [PC/Laptop] šļø Complete property portfolio management suite. Real-time dashboard with color-coded alerts, tenant intelligence with permanent history, ROI tracking across portfolio/property types, market analysis with IQR filtering, smart lease forms with auto-fill & live calculator, notes system, export/import, auto-rotating storage. Join investors managing millions in property value!
// @author Sprinkers [4056515]
// @match https://*.torn.com/properties.php*
// @grant GM_xmlhttpRequest
// @grant window.close
// @connect api.torn.com
// @run-at document-end
// @namespace https://greasyfork.org/users/1576692
// ==/UserScript==
(function() {
'use strict';
const CONFIG = {
API_BATCH_SIZE: 100,
DEFAULT_SECOND_PROPNAV: 10,
DEFAULT_ITEMS_PER_PAGE: 10,
MIN_API_KEY_LENGTH: 16,
DEFAULT_WARN_EXPIRE: 3,
DEFAULT_WARN_CLOSE: 7,
DEFAULT_HISTORY_LIMIT: 10,
STORAGE_LIMIT_KB: 5000,
STORAGE_WARN_LEVEL: 60,
STORAGE_CRITICAL_LEVEL: 85,
MAX_RETRY_ATTEMPTS: 3
};
const KEY_BINDS = {
CLOSE_ALL: 'Escape'
};
const RATE_LIMIT = {
CALLS_PER_MINUTE: 50,
CALLS_PER_HOUR: 1000,
MIN_INTERVAL_MS: 1200,
lastCallTime: 0,
callCount: {
minute: 0,
hour: 0,
lastMinuteReset: Date.now(),
lastHourReset: Date.now()
},
retryCount: 0,
maxRetries: 3,
notificationShown: false
};
const TAB_COORDINATION = {
PROPERTIES_LOCK: 'tpm_tab_lock_properties',
LOGS_LOCK: 'tpm_tab_lock_logs',
VERIFY_LOCK: 'tpm_tab_lock_verify',
LOCK_TIMEOUT: 10000,
lastUpdate: Date.now()
};
const EVENT_CODES = {
PROPERTY_RENTED: 1,
RENTAL_EXPIRED: 2,
EXTENSION_OFFERED: 3,
EXTENSION_ACCEPTED: 4,
OFFER_DECLINED: 5,
LEASE_ENDED: 6,
EXTENSION_WITHDRAWN: 7
};
const EVENT_NAMES = {
1: 'property_rented',
2: 'rental_expired',
3: 'extension_offered',
4: 'extension_accepted',
5: 'offer_declined',
6: 'lease_ended',
7: 'extension_withdrawn'
};
const STORE_EVENTS = {
UPDATED: 'tpm_store_updated'
};
const EVENT_MAP = {
5937: EVENT_CODES.PROPERTY_RENTED,
5939: EVENT_CODES.RENTAL_EXPIRED,
5940: EVENT_CODES.EXTENSION_OFFERED,
5943: EVENT_CODES.EXTENSION_ACCEPTED,
5948: EVENT_CODES.OFFER_DECLINED,
5951: EVENT_CODES.LEASE_ENDED,
5953: EVENT_CODES.EXTENSION_WITHDRAWN
};
const TPM_USER_AGENT = 'TPM.3.0.0';
const tabChannel = new BroadcastChannel('tpm_tab_sync');
let tpmArchive = {};
const STATUS_DISPLAY = {
'rented': 'š¢',
'none': 'ā ļøšŖ§',
'for_rent': 'š',
'for_sale': 'š¢',
'in_use': 'š',
'undefined': 'ā¢ļø'
};
const STYLES = {
container: 'width:96%;background:var(--tpm-bg);padding:15px;border-radius:8px;line-height:1.4;',
input: 'width:98%;padding:8px;background:var(--tpm-input-bg);color:var(--tpm-text);border:1px solid var(--tpm-border);border-radius:4px;font-size:13px;',
button: 'background: var(--tpm-button-bg); color: var(--tpm-text); border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size:12px; transition: background 0.2s;',
statusColors: {
expired: 'rgba(180, 50, 50, 0.25)',
expiredfull: 'rgba(180, 50, 50, 0.9)',
warning: 'rgba(200, 80, 200, 0.25)',
warningfull: 'rgba(200, 80, 200, 0.9)',
gettingclose: 'rgba(200, 180, 50, 0.25)',
gettingclosefull: 'rgba(200, 180, 50, 0.9)',
forsale: 'rgba(120, 40, 120, 0.25)',
forsalefull: 'rgba(120, 40, 120, 0.9)',
forrent: 'rgba(40, 180, 100, 0.25)',
forrentfull: 'rgba(40, 180, 100, 0.9)',
offered: 'rgba(100, 70, 150, 0.25)',
offeredfull: 'rgba(100, 70, 150, 0.9)',
offerask: 'rgba(100, 70, 150, 0.25)',
offeraskfull: 'rgba(100, 70, 150, 0.9)',
allgood: 'rgba(30, 70, 150, 0.25)',
allgoodfull: 'rgba(30, 70, 150, 0.9)',
hover: 'rgba(30, 90, 20, 0.35)'
}
};
function safeJSON(key) {
try {
const data = localStorage.getItem(key);
if (!data) return key === 'prop_cache' ? [] : {};
const parsed = JSON.parse(data);
return (typeof parsed === 'object' && parsed !== null) ? parsed : (key === 'prop_cache' ? [] : {});
} catch (e) {
console.error(`TPM: Storage corrupted for ${key}:`, e);
const backup = key === 'prop_cache' ? [] : {};
localStorage.setItem(key, JSON.stringify(backup));
return backup;
}
}
function showUpdatePopup(text) {
const overlay = document.createElement("div");
overlay.className = "tpm-update-overlay";
overlay.innerHTML = `
<div class="tpm-update-modal">
<div class="tpm-update-title">š Torn Property Manager Update</div>
<div class="tpm-update-body">${text}</div>
<div class="tpm-update-close" style="text-align:right; margin-top:15px;">
<button class="tpm-button" id="tpm-update-close-btn">Close</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById("tpm-update-close-btn").onclick = () => overlay.remove();
}
function enforceVersionReset() {
const storedVersion = localStorage.getItem('tpm_script_version');
const apiKey = localStorage.getItem('tpmApiKey');
const SCRIPT_VERSION = "TPM.V3.0.0";
const UPDATE_LOGS = {
"TPM.V3.0.0": `<div style="font-family: Arial, sans-serif; line-height: 1.6;">
<div style="font-size: 16px; font-weight: bold; color: #4CAF50; margin-bottom: 10px;">š¢ Torn Property Manager v3.0.0</div>
<ul style="margin: 0 0 10px 0; padding-left: 20px;">
<li>Complete portfolio ROI tracking</li>
<li>Responsive dark/light modes to torns settings</li>
<li>Individual property ROI percentages</li>
<li>Vacancy loss calculation (monthly)</li>
<li>Market price storage for accurate valuations</li>
<li>Fixed lease form auto-fill with last records</li>
<li>30/60/90 day income projections</li>
<li>Auto-fill with last recorded values</li>
<li>Notes Book tenant system</li>
<li>Smart storage with auto-rotation</li>
<li>Cross-tab synchronization</li>
<li>Improved rate limit handling</li>
<li>Escape key closes sections</li>
</ul>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #444;">
ā Now requires FULL ACCESS API key for log reading<br>
š See Help for complete documentation
</div>
</div>`
};
if (storedVersion !== SCRIPT_VERSION) {
console.log(`TPM Update: ${storedVersion} -> ${SCRIPT_VERSION}`);
let message = UPDATE_LOGS[SCRIPT_VERSION] || "Torn Property Manager Updated.";
if (apiKey) {
localStorage.removeItem('tpmApiKey');
message += "\n\nā For security, please re-enter your API key.";
}
showUpdatePopup(message);
localStorage.setItem('tpm_script_version', SCRIPT_VERSION);
}
}
enforceVersionReset();
function checkRateLimit() {
const now = Date.now();
if (now - RATE_LIMIT.callCount.lastMinuteReset > 60000) {
RATE_LIMIT.callCount.minute = 0;
RATE_LIMIT.callCount.lastMinuteReset = now;
RATE_LIMIT.notificationShown = false;
RATE_LIMIT.consecutiveRateLimits = 0;
}
if (now - RATE_LIMIT.callCount.lastHourReset > 3600000) {
RATE_LIMIT.callCount.hour = 0;
RATE_LIMIT.callCount.lastHourReset = now;
RATE_LIMIT.notificationShown = false;
RATE_LIMIT.consecutiveRateLimits = 0;
}
const timeSinceLastCall = now - RATE_LIMIT.lastCallTime;
if (RATE_LIMIT.lastCallTime !== 0 && timeSinceLastCall < RATE_LIMIT.MIN_INTERVAL_MS) {
const waitTime = RATE_LIMIT.MIN_INTERVAL_MS - timeSinceLastCall;
if (isDebugMode()) console.log(`TPM: Rate limit - waiting ${waitTime}ms`);
RATE_LIMIT.consecutiveRateLimits = (RATE_LIMIT.consecutiveRateLimits || 0) + 1;
if (!RATE_LIMIT.notificationShown && RATE_LIMIT.consecutiveRateLimits >= 10) {
showNotification('ā ļø Rate limiting active: Protecting your account. API calls are now spaced 1.2 seconds apart.', 'warning');
RATE_LIMIT.notificationShown = true;
}
return waitTime;
}
if (RATE_LIMIT.callCount.minute >= RATE_LIMIT.CALLS_PER_MINUTE) {
const waitTime = 60000 - (now - RATE_LIMIT.callCount.lastMinuteReset);
if (isDebugMode()) console.log(`TPM: Minute rate limit reached, waiting ${waitTime}ms`);
RATE_LIMIT.consecutiveRateLimits = (RATE_LIMIT.consecutiveRateLimits || 0) + 1;
if (!RATE_LIMIT.notificationShown && RATE_LIMIT.consecutiveRateLimits >= 10) {
showNotification('ā ļø Minute rate limit active: Protecting your account. Maximum 50 calls per minute enforced.', 'warning');
RATE_LIMIT.notificationShown = true;
}
return waitTime;
}
if (RATE_LIMIT.callCount.hour >= RATE_LIMIT.CALLS_PER_HOUR) {
const waitTime = 3600000 - (now - RATE_LIMIT.callCount.lastHourReset);
if (isDebugMode()) console.log(`TPM: Hour rate limit reached, waiting ${waitTime}ms`);
RATE_LIMIT.consecutiveRateLimits = (RATE_LIMIT.consecutiveRateLimits || 0) + 1;
if (!RATE_LIMIT.notificationShown && RATE_LIMIT.consecutiveRateLimits >= 10) {
showNotification('ā ļø Hourly rate limit active: Protecting your account. Maximum 1000 calls per hour enforced.', 'warning');
RATE_LIMIT.notificationShown = true;
}
return waitTime;
}
RATE_LIMIT.consecutiveRateLimits = 0;
return 0;
}
function updateRateLimitCounters() {
RATE_LIMIT.lastCallTime = Date.now();
RATE_LIMIT.callCount.minute++;
RATE_LIMIT.callCount.hour++;
RATE_LIMIT.retryCount = 0;
}
function acquireTabLock(lockKey) {
const now = Date.now();
const lockData = localStorage.getItem(lockKey);
if (lockData) {
try {
const lock = JSON.parse(lockData);
if (now - lock.timestamp < TAB_COORDINATION.LOCK_TIMEOUT) {
return false;
}
} catch (e) {}
}
const newLock = {
tabId: Math.random().toString(36).substr(2, 9),
timestamp: now
};
localStorage.setItem(lockKey, JSON.stringify(newLock));
const verifyLock = JSON.parse(localStorage.getItem(lockKey));
return verifyLock.tabId === newLock.tabId;
}
function releaseTabLock(lockKey) {
localStorage.removeItem(lockKey);
}
function waitForTabUpdate(lockKey, timeout = 10000) {
return new Promise((resolve) => {
const startTime = Date.now();
const checkInterval = setInterval(() => {
const lockExists = localStorage.getItem(lockKey);
const lastCall = parseInt(localStorage.getItem(lockKey === TAB_COORDINATION.PROPERTIES_LOCK ? 'prop_last_call' : 'tenant_logs_last_call')) || 0;
if (!lockExists && lastCall > TAB_COORDINATION.lastUpdate) {
clearInterval(checkInterval);
resolve(true);
} else if (Date.now() - startTime > timeout) {
clearInterval(checkInterval);
resolve(false);
}
}, 500);
});
}
function initOptimizedStorage() {
if (!localStorage.getItem('tpm_store_v2')) {
const oldLogs = safeJSON("tpm_lease_logs");
const oldTenants = safeJSON("tpm_tenant_database");
const newStore = {
events: {},
tenants: {},
properties: {},
archive: tpmArchive,
version: 2
};
if (oldTenants && typeof oldTenants === 'object') {
Object.entries(oldTenants).forEach(([id, t]) => {
newStore.tenants[id] = {
n: t.name || 'Unknown',
f: t.firstSeen || Date.now(),
l: t.lastSeen || Date.now(),
a: t.aliases || [],
c: t.totalLeases || 0,
s: t.reliabilityScore || 50
};
});
}
if (oldLogs && typeof oldLogs === 'object') {
Object.entries(oldLogs).forEach(([propID, propData]) => {
if (propData && typeof propData === 'object') {
newStore.events[propID] = [];
Object.entries(propData).forEach(([tenantID, events]) => {
if (Array.isArray(events)) {
events.forEach(e => {
if (e && e.time) {
const eventCode = e.logType ?
(EVENT_MAP[e.logType] || 0) :
(e.event === 'property_rented' ? 1 :
e.event === 'rental_expired' ? 2 :
e.event === 'extension_offered' ? 3 :
e.event === 'extension_accepted' ? 4 :
e.event === 'offer_declined' ? 5 :
e.event === 'lease_ended' ? 6 :
e.event === 'extension_withdrawn' ? 7 : 0);
newStore.events[propID].push([
e.time || 0,
eventCode,
e.rent || 0,
e.days || 0,
e.rent || 0,
tenantID
]);
}
});
}
});
}
});
}
localStorage.setItem('tpm_store_v2', JSON.stringify(newStore));
}
}
initOptimizedStorage();
tpmArchive = safeJSON("tpm_archive");
if (!tpmArchive.totalLifetimeIncome) {
tpmArchive = {
totalLifetimeIncome: 0,
totalLifetimeLeases: 0,
totalUniqueTenants: 0,
yearlyIncome: {},
propertyTypes: {},
lastArchiveUpdate: Date.now(),
totalPropertiesArchived: 0
};
localStorage.setItem('tpm_archive', JSON.stringify(tpmArchive));
}
function getStore() {
try {
const storeStr = localStorage.getItem('tpm_store_v2');
return storeStr ? JSON.parse(storeStr) : { events: {}, tenants: {}, archive: tpmArchive, version: 2 };
} catch (e) {
console.error('TPM: Store corrupted, creating new store', e);
const newStore = { events: {}, tenants: {}, archive: tpmArchive, version: 2 };
localStorage.setItem('tpm_store_v2', JSON.stringify(newStore));
return newStore;
}
}
function saveStore(store) {
try {
localStorage.setItem('tpm_store_v2', JSON.stringify(store));
tabChannel.postMessage({
type: 'STORE_UPDATED',
data: { store: store, archive: tpmArchive },
timestamp: Date.now()
});
window.dispatchEvent(new CustomEvent(STORE_EVENTS.UPDATED, {
detail: { store, timestamp: Date.now() }
}));
} catch (e) {
console.error('TPM: Failed to save store', e);
}
}
function addEventToStore(propertyID, tenantID, eventCode, dailyRent, days, totalPayment, time) {
if (!propertyID || propertyID === 'unknown' || !tenantID || tenantID === 'unknown') return;
if (typeof dailyRent !== 'number' || isNaN(dailyRent)) dailyRent = 0;
if (typeof days !== 'number' || isNaN(days)) days = 0;
if (typeof totalPayment !== 'number' || isNaN(totalPayment)) totalPayment = 0;
const store = getStore();
if (!store.events) store.events = {};
if (!store.events[propertyID]) store.events[propertyID] = [];
const compressed = [
time || Date.now(),
eventCode || 0,
Math.round(dailyRent) || 0,
days || 0,
Math.round(totalPayment) || 0,
String(tenantID)
];
store.events[propertyID].push(compressed);
if (store.events[propertyID].length > 500) {
store.events[propertyID] = store.events[propertyID].slice(-250);
}
saveStore(store);
}
function storeTenantInfo(tenantID, tenantName) {
if (!tenantID || tenantID === 'unknown' || tenantID === '0' || tenantID === '') return;
if (!tenantName || tenantName === 'Unknown' || tenantName === 'Unknown Tenant') {
tenantName = 'Unknown';
}
if (tenantName.toLowerCase().includes('unknown') && tenantID.length < 5) return;
if (tenantName.toLowerCase().includes('empty') && tenantID.length < 5) return;
const store = getStore();
if (!store.tenants) store.tenants = {};
if (!store.tenants[tenantID]) {
store.tenants[tenantID] = {
n: tenantName,
f: Date.now(),
l: Date.now(),
a: [],
c: 0,
s: 50
};
} else {
if (store.tenants[tenantID].n !== tenantName && !store.tenants[tenantID].a.includes(tenantName) && tenantName !== 'Unknown') {
store.tenants[tenantID].a.push(tenantName);
}
store.tenants[tenantID].l = Date.now();
if (store.tenants[tenantID].n === 'Unknown' || !store.tenants[tenantID].n || store.tenants[tenantID].n === 'Unknown Tenant') {
store.tenants[tenantID].n = tenantName;
}
}
saveStore(store);
}
function showNotesBook(initialTenantID = null, initialTenantName = null, initialIsActive = null) {
const overlay = document.createElement("div");
overlay.className = "tpm-update-overlay";
overlay.innerHTML = `
<div class="tpm-note-popup">
<h4 style="color:var(--tpm-info); margin:0 0 10px 0; display:flex; justify-content:space-between;">
<span>š Notes Book</span>
<span style="cursor:pointer; font-size:16px;" id="close-notes-book">ā</span>
</h4>
<div class="tpm-notes-book">
<div class="tpm-notes-sidebar">
<input type="text" class="tpm-notes-search" placeholder="š Search by name..." id="notes-search">
<div class="tpm-filter-options">
<label><input type="checkbox" id="show-inactive" checked> Show inactive</label>
</div>
<div id="notes-list-container" style="overflow-y:auto; max-height:300px;">
<div class="tpm-loading"><div class="tpm-spinner"></div><span>Loading tenants...</span></div>
</div>
<div class="tpm-notes-total" id="notes-total">Loading...</div>
</div>
<div class="tpm-notes-page" id="notes-page">
<div style="text-align:center; color:var(--tpm-text-soft); padding:20px;">Select a tenant from the list</div>
</div>
</div>
<div style="margin-top:10px; display:flex; gap:10px; justify-content:flex-end;">
<button class="tpm-button" id="clear-all-notes">šļø Clear All Notes</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById("close-notes-book").onclick = () => overlay.remove();
const searchInput = document.getElementById("notes-search");
const showInactiveCheck = document.getElementById("show-inactive");
const listContainer = document.getElementById("notes-list-container");
const notesPage = document.getElementById("notes-page");
const notesTotal = document.getElementById("notes-total");
let currentTenant = null;
function loadTenantList() {
const properties = JSON.parse(localStorage.getItem('prop_cache') || "[]");
const store = getStore();
const tenantMap = new Map();
properties.forEach(([id, p]) => {
if (p.rented_by && p.rented_by.id) {
const tenantId = String(p.rented_by.id);
const tenantName = p.rented_by.name;
if (tenantName && tenantName !== "Unknown" && !tenantName.toLowerCase().includes("empty")) {
if (!tenantMap.has(tenantId)) {
tenantMap.set(tenantId, {
id: tenantId,
name: tenantName,
isActive: true
});
}
}
}
});
if (store.events) {
Object.entries(store.events).forEach(([propID, events]) => {
events.forEach(e => {
const tenantId = e[5];
if (tenantId && tenantId.length > 5 && !tenantMap.has(tenantId)) {
const tenantName = store.tenants?.[tenantId]?.n || 'Unknown';
if (tenantName !== 'Unknown' && !tenantName.toLowerCase().includes('empty')) {
tenantMap.set(tenantId, {
id: tenantId,
name: tenantName,
isActive: false
});
}
}
});
});
}
const notes = safeJSON("tpm_tenant_notes");
Object.keys(notes).forEach(tenantId => {
if (!tenantMap.has(tenantId)) {
const firstNote = notes[tenantId][0] || {};
const tenantName = firstNote.tenantName;
if (tenantName && tenantName !== "Unknown" && !tenantName.toLowerCase().includes("empty")) {
tenantMap.set(tenantId, {
id: tenantId,
name: tenantName,
isActive: firstNote.isActive || false
});
}
}
});
const tenants = Array.from(tenantMap.values()).map(t => ({
...t,
count: notes[t.id]?.length || 0,
hasNotes: notes[t.id]?.length > 0
})).sort((a,b) => a.name.localeCompare(b.name));
const searchTerm = searchInput.value.toLowerCase();
const showInactive = showInactiveCheck.checked;
const filtered = tenants.filter(t =>
(showInactive || t.isActive) &&
t.name.toLowerCase().includes(searchTerm)
);
notesTotal.textContent = `${filtered.length} tenants (${tenants.length} total)`;
if (filtered.length === 0) {
listContainer.innerHTML = '<div style="color:var(--tpm-text-soft); text-align:center; padding:10px;">No tenants found</div>';
return;
}
listContainer.innerHTML = '<ul class="tpm-notes-list">' + filtered.map(t => `
<li class="${t.id === currentTenant ? 'active' : ''} ${t.isActive ? '' : 'inactive'}" data-tenant="${t.id}" data-name="${t.name}">
<span class="tpm-notes-icon">${t.hasNotes ? 'š' : 'š'}</span>
<span class="tpm-notes-name">${t.name} [${t.id}]</span>
<span class="tpm-notes-count">(${t.count})</span>
</li>
`).join('') + '</ul>';
listContainer.querySelectorAll('li').forEach(li => {
li.onclick = () => {
document.querySelectorAll('.tpm-notes-list li').forEach(l => l.classList.remove('active'));
li.classList.add('active');
const tenantId = li.dataset.tenant;
const tenantName = li.dataset.name;
currentTenant = tenantId;
loadTenantNotes(tenantId, tenantName);
};
});
if (filtered.length === 1 && !currentTenant) {
const first = filtered[0];
currentTenant = first.id;
document.querySelector(`li[data-tenant="${first.id}"]`)?.classList.add('active');
loadTenantNotes(first.id, first.name);
} else if (initialTenantID && !currentTenant) {
setTimeout(() => {
const match = filtered.find(t => t.id === String(initialTenantID));
if (match) {
currentTenant = initialTenantID;
document.querySelector(`li[data-tenant="${initialTenantID}"]`)?.classList.add('active');
loadTenantNotes(initialTenantID, initialTenantName || match.name);
}
}, 100);
}
}
function loadTenantNotes(tenantId, tenantName) {
notesPage.innerHTML = '<div class="tpm-loading"><div class="tpm-spinner"></div><span>Loading notes...</span></div>';
setTimeout(() => {
const notes = safeJSON("tpm_tenant_notes")[tenantId] || [];
const notesHtml = notes.map((note, index) => `
<div class="tpm-note-item ${note.isActive ? 'active' : 'inactive'}">
<span class="tpm-note-remove" data-tenant="${tenantId}" data-index="${index}">ā</span>
<div style="margin-bottom:4px;">${note.note}</div>
<div class="tpm-note-date">${note.formattedDate} ${note.isActive ? '⢠Active' : '⢠Inactive'}</div>
</div>
`).join('');
notesPage.innerHTML = `
<h5 style="margin:0 0 10px 0; color:var(--tpm-info);">${tenantName} [${tenantId}]</h5>
<div style="max-height:300px; overflow-y:auto; margin-bottom:10px;">
${notesHtml || '<div style="color:var(--tpm-text-soft); text-align:center; padding:10px;">No notes for this tenant. Add one below!</div>'}
</div>
<textarea id="note-text-${tenantId}" placeholder="Enter note (max 1000 characters)..." maxlength="1000" style="width: 100%;box-sizing: border-box;resize: vertical;min-height: 80px;padding: 8px;margin: 5px 0;background: var(--tpm-input-bg);color: var(--tpm-text-strong);border: 1px solid var(--tpm-border);border-radius: 4px;font-size: 12px;font-family: inherit;"></textarea>
<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:10px;">
<button class="tpm-button" style="background:var(--tpm-success-btn-bg);" id="save-note-${tenantId}">Add Note</button>
</div>
`;
document.getElementById(`save-note-${tenantId}`).onclick = () => {
const text = document.getElementById(`note-text-${tenantId}`).value.trim();
if (text) {
const isActive = notes.length ? notes[0].isActive : true;
addTenantNote(tenantId, tenantName, text, isActive);
loadTenantNotes(tenantId, tenantName);
loadTenantList();
}
};
notesPage.querySelectorAll('.tpm-note-remove').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const tenant = btn.dataset.tenant;
const index = parseInt(btn.dataset.index);
removeTenantNote(tenant, index);
loadTenantNotes(tenant, tenantName);
loadTenantList();
};
});
}, 300);
}
document.getElementById('clear-all-notes').onclick = () => {
if (confirm('ā ļø Are you sure? This will delete ALL tenant notes permanently.')) {
localStorage.removeItem('tpm_tenant_notes');
showNotification('All notes cleared', 'info');
overlay.remove();
}
};
searchInput.oninput = () => loadTenantList();
showInactiveCheck.onchange = () => loadTenantList();
loadTenantList();
}
function getTenantName(tenantID) {
if (!tenantID || tenantID === 'unknown') return 'Unknown Tenant';
const store = getStore();
return store.tenants?.[tenantID]?.n || 'Unknown Tenant';
}
function updateTenantStats(tenantID, income, days, skipIfRecent) {
if (!tenantID || tenantID === 'unknown') return;
const store = getStore();
if (store.tenants?.[tenantID]) {
if (skipIfRecent && Date.now() - store.tenants[tenantID].l < 60000) return;
store.tenants[tenantID].c = (store.tenants[tenantID].c || 0) + 1;
let score = store.tenants[tenantID].s || 50;
if (days >= 60) score += 5;
if (days >= 30) score += 3;
if (days < 7) score -= 5;
if (store.tenants[tenantID].c > 1) score += 3;
store.tenants[tenantID].s = Math.max(0, Math.min(100, score));
saveStore(store);
}
}
function cleanupTenantDB() {
const store = getStore();
if (!store.tenants) return;
const twoYearsAgo = Date.now() - (730 * 24 * 60 * 60 * 1000);
let removed = 0;
Object.keys(store.tenants).forEach(id => {
if (store.tenants[id].l < twoYearsAgo && (store.tenants[id].c || 0) === 0 && store.tenants[id].c < 2) {
delete store.tenants[id];
removed++;
}
});
if (removed > 0) {
saveStore(store);
if (isDebugMode()) console.log(`TPM: Cleaned up ${removed} inactive tenants`);
}
}
function showNotification(message, type = 'info') {
const overlay = document.createElement("div");
overlay.className = "tpm-notification-overlay";
const colors = {
success: { bg: 'var(--tpm-note-item-active)', border: 'var(--tpm-success)', icon: 'ā
' },
error: { bg: 'var(--tpm-note-item-inactive)', border: 'var(--tpm-danger)', icon: 'ā' },
warning: { bg: 'var(--tpm-note-item-inactive)', border: 'var(--tpm-warning)', icon: 'ā ļø' },
info: { bg: 'var(--tpm-card-bg)', border: 'var(--tpm-info)', icon: 'ā¹ļø' }
};
const color = colors[type] || colors.info;
overlay.innerHTML = `
<div class="tpm-notification-modal" style="background:${color.bg}; border-color:${color.border};">
<div class="tpm-notification-title" style="color:${color.border};">${color.icon} Torn Property Manager</div>
<div class="tpm-notification-body">${message}</div>
<div class="tpm-notification-close">
<button class="tpm-button" id="tpm-notification-close-btn">OK</button>
</div>
</div>
`;
document.body.appendChild(overlay);
let timeoutId;
if (type !== 'error') {
timeoutId = setTimeout(() => {
if (document.body.contains(overlay)) overlay.remove();
}, 5000);
}
document.getElementById("tpm-notification-close-btn").onclick = () => {
if (timeoutId) clearTimeout(timeoutId);
overlay.remove();
};
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
if (timeoutId) clearTimeout(timeoutId);
overlay.remove();
}
});
}
function updateArchiveFromEvents(events, propertyType) {
if (!events || !Array.isArray(events)) return;
events.forEach(e => {
const income = Array.isArray(e) ? e[4] : (e.income || 0);
if (!income) return;
tpmArchive.totalLifetimeIncome += income;
tpmArchive.totalLifetimeLeases++;
const time = Array.isArray(e) ? e[0] : e.time;
const year = new Date(time).getFullYear();
tpmArchive.yearlyIncome[year] = (tpmArchive.yearlyIncome[year] || 0) + income;
if (!tpmArchive.propertyTypes[propertyType]) {
tpmArchive.propertyTypes[propertyType] = { totalIncome: 0, totalLeases: 0 };
}
tpmArchive.propertyTypes[propertyType].totalIncome += income;
tpmArchive.propertyTypes[propertyType].totalLeases++;
});
localStorage.setItem('tpm_archive', JSON.stringify(tpmArchive));
}
function getStoragePressure() {
let totalBytes = 0;
for (let key in localStorage) {
if (key.startsWith('tpm_')) totalBytes += localStorage[key].length * 2;
}
return (totalBytes / 1024 / CONFIG.STORAGE_LIMIT_KB) * 100;
}
function getRetentionMonths(pressure) {
if (pressure < 70) return 999;
if (pressure < 80) return 36;
if (pressure < 90) return 24;
return 12;
}
function rotateLogsIfNeeded() {
const pressure = getStoragePressure();
if (pressure < 70) return;
const store = getStore();
if (!store.events) return;
const cutoffMonths = getRetentionMonths(pressure);
const cutoffTime = Date.now() - (cutoffMonths * 30 * 24 * 60 * 60 * 1000);
let rotated = 0;
let totalEvents = 0;
Object.keys(store.events).forEach(propID => {
totalEvents += store.events[propID].length;
});
const targetRemovalPercent = pressure > 95 ? 0.4 : pressure > 90 ? 0.3 : 0.2;
const targetRemoval = Math.floor(totalEvents * targetRemovalPercent);
let removed = 0;
Object.keys(store.events).forEach(propID => {
if (removed >= targetRemoval) return;
const [oldEvents, keptEvents] = store.events[propID].reduce((acc, e) => {
if (removed < targetRemoval && (e[0] < cutoffTime || pressure > 95)) {
acc[0].push(e);
removed++;
} else {
acc[1].push(e);
}
return acc;
}, [[], []]);
if (oldEvents.length) {
updateArchiveFromEvents(oldEvents, 'Unknown');
rotated += oldEvents.length;
}
store.events[propID] = keptEvents;
});
saveStore(store);
if (pressure > 95) {
showNotification('ā ļø CRITICAL: Storage at ' + pressure.toFixed(1) + '%. Automatic cleanup performed. Consider exporting and clearing old data.', 'warning');
} else if (pressure > 90) {
showNotification('ā ļø Storage at ' + pressure.toFixed(1) + '%. Old lease records being rotated to save space.', 'warning');
} else if (rotated > 0) {
showNotification(`š§¹ Rotated ${rotated} old events to save space (keeping last ${cutoffMonths} months)`, 'info');
}
}
function getCustoms() {
return {
propnavbar: parseInt(localStorage.getItem('propnavbar')) || CONFIG.DEFAULT_SECOND_PROPNAV,
items_per_page: parseInt(localStorage.getItem('items_per_page')) || CONFIG.DEFAULT_ITEMS_PER_PAGE,
warn_lease_ex: parseInt(localStorage.getItem('warn_lease_ex')) || CONFIG.DEFAULT_WARN_EXPIRE,
warn_lease_wa: parseInt(localStorage.getItem('warn_lease_wa')) || CONFIG.DEFAULT_WARN_CLOSE,
historyLimit: parseInt(localStorage.getItem('history_limit')) || CONFIG.DEFAULT_HISTORY_LIMIT
};
}
function getLinkTarget() {
const pref = localStorage.getItem('tpm_link_target') || 'new';
return pref === 'same' ? '_self' : '_blank';
}
function isLeaseAutofillEnabled() {
const val = localStorage.getItem('tpm_autofill_lease');
return val === null ? true : val === 'true';
}
function isDebugMode() {
return localStorage.getItem('tpm_debug_mode') === 'true';
}
function updateThemeVariables() {
const isDarkMode = document.body.classList.contains('dark-mode');
const root = document.documentElement;
if (isDarkMode) {
root.style.setProperty('--tpm-bg', '#2d2d2d');
root.style.setProperty('--tpm-card-bg', '#1f1f1f');
root.style.setProperty('--tpm-card-header-bg', '#262626');
root.style.setProperty('--tpm-card-footer-bg', '#1a1a1a');
root.style.setProperty('--tpm-table-bg', '#262626');
root.style.setProperty('--tpm-table-header-bg', '#1f1f1f');
root.style.setProperty('--tpm-input-bg', '#333');
root.style.setProperty('--tpm-button-bg', '#3a3a3a');
root.style.setProperty('--tpm-button-hover', '#4a4a4a');
root.style.setProperty('--tpm-border', '#333');
root.style.setProperty('--tpm-text', '#ddd');
root.style.setProperty('--tpm-text-soft', '#aaa');
root.style.setProperty('--tpm-text-strong', '#fff');
root.style.setProperty('--tpm-link', '#b5d6ff');
root.style.setProperty('--tpm-success', '#9fdf9f');
root.style.setProperty('--tpm-info', '#b5d6ff');
root.style.setProperty('--tpm-warning', '#ffe08c');
root.style.setProperty('--tpm-danger', '#ffa6a6');
root.style.setProperty('--tpm-alert', '#ffa6ff');
root.style.setProperty('--tpm-nav-bg', 'rgba(255,255,255,0.05)');
root.style.setProperty('--tpm-nav-item-bg', '#444');
root.style.setProperty('--tpm-nav-item-hover', '#555');
root.style.setProperty('--tpm-divider', '#444');
root.style.setProperty('--tpm-note-popup-bg', '#262626');
root.style.setProperty('--tpm-note-item-active', '#1a3a1a');
root.style.setProperty('--tpm-note-item-inactive', '#3a3a3a');
root.style.setProperty('--tpm-note-remove', '#ffa6a6');
root.style.setProperty('--tpm-note-remove-hover', '#ff6b6b');
root.style.setProperty('--tpm-notes-sidebar-border', '#444');
root.style.setProperty('--tpm-notes-search-bg', '#333');
root.style.setProperty('--tpm-notes-list-hover', '#333');
root.style.setProperty('--tpm-notes-list-active', '#1a3a1a');
root.style.setProperty('--tpm-danger-btn-bg', '#8b3a3a');
root.style.setProperty('--tpm-danger-btn-hover', '#a54545');
root.style.setProperty('--tpm-success-btn-bg', '#2a5a2a');
root.style.setProperty('--tpm-success-btn-hover', '#357535');
root.style.setProperty('--tpm-calculator-bg', '#1a2a1a');
root.style.setProperty('--tpm-calculator-border', '#2a3a2a');
} else {
root.style.setProperty('--tpm-bg', '#f5f5f5');
root.style.setProperty('--tpm-card-bg', '#f0f0f0');
root.style.setProperty('--tpm-card-header-bg', '#e8e8e8');
root.style.setProperty('--tpm-card-footer-bg', '#e0e0e0');
root.style.setProperty('--tpm-table-bg', '#f5f5f5');
root.style.setProperty('--tpm-table-header-bg', '#e8e8e8');
root.style.setProperty('--tpm-input-bg', '#fff');
root.style.setProperty('--tpm-button-bg', '#e0e0e0');
root.style.setProperty('--tpm-button-hover', '#d0d0d0');
root.style.setProperty('--tpm-border', '#ccc');
root.style.setProperty('--tpm-text', '#333');
root.style.setProperty('--tpm-text-soft', '#666');
root.style.setProperty('--tpm-text-strong', '#000');
root.style.setProperty('--tpm-link', '#0066cc');
root.style.setProperty('--tpm-success', '#2e7d32');
root.style.setProperty('--tpm-info', '#0277bd');
root.style.setProperty('--tpm-warning', '#b76e00');
root.style.setProperty('--tpm-danger', '#c62828');
root.style.setProperty('--tpm-alert', '#aa00aa');
root.style.setProperty('--tpm-nav-bg', 'rgba(0,0,0,0.05)');
root.style.setProperty('--tpm-nav-item-bg', '#ddd');
root.style.setProperty('--tpm-nav-item-hover', '#ccc');
root.style.setProperty('--tpm-divider', '#aaa');
root.style.setProperty('--tpm-note-popup-bg', '#f5f5f5');
root.style.setProperty('--tpm-note-item-active', '#c8e6c9');
root.style.setProperty('--tpm-note-item-inactive', '#e0e0e0');
root.style.setProperty('--tpm-note-remove', '#c62828');
root.style.setProperty('--tpm-note-remove-hover', '#b71c1c');
root.style.setProperty('--tpm-notes-sidebar-border', '#ccc');
root.style.setProperty('--tpm-notes-search-bg', '#fff');
root.style.setProperty('--tpm-notes-list-hover', '#e0e0e0');
root.style.setProperty('--tpm-notes-list-active', '#c8e6c9');
root.style.setProperty('--tpm-danger-btn-bg', '#dc3545');
root.style.setProperty('--tpm-danger-btn-hover', '#c82333');
root.style.setProperty('--tpm-success-btn-bg', '#28a745');
root.style.setProperty('--tpm-success-btn-hover', '#218838');
root.style.setProperty('--tpm-calculator-bg', '#e8f0e8');
root.style.setProperty('--tpm-calculator-border', '#c0d0c0');
}
}
updateThemeVariables();
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
updateThemeVariables();
}
});
});
themeObserver.observe(document.body, { attributes: true });
const TPM_UI_STYLE = `
<style>
.tpm-stats-bar{display:grid;grid-template-columns: repeat(auto-fit,minmax(100px,1fr));gap:8px;margin-bottom:12px;}
.tpm-stat{background:var(--tpm-card-bg);border:1px solid var(--tpm-border);border-radius:8px;padding:8px;text-align:center;}
.tpm-stat-label{display:block;font-size:11px;color:var(--tpm-text-soft);margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;}
.tpm-stat-value{font-size:18px;font-weight:700;color:var(--tpm-text-strong);}
.tpm-stat.danger .tpm-stat-value{color:var(--tpm-danger);}
.tpm-panel-close{position:absolute;top:8px;right:10px;font-size:13px;cursor:pointer;color:var(--tpm-text-soft);padding:4px 8px;border-radius:4px;}
.tpm-panel-close:hover{background:var(--tpm-button-bg);color:var(--tpm-text-strong);}
.tpm-stat.alert .tpm-stat-value{color:var(--tpm-alert);}
.tpm-stat.warn .tpm-stat-value{color:var(--tpm-warning);}
.tpm-card{background:var(--tpm-card-bg);border-radius:8px;border:1px solid var(--tpm-border);overflow:hidden;margin-bottom:10px;}
.tpm-card-header{background:var(--tpm-card-header-bg);padding:10px 12px;border-bottom:1px solid var(--tpm-border);font-size:14px;font-weight:600;cursor:pointer;}
.tpm-card-header:hover{background:var(--tpm-button-hover);}
.tpm-card-body{padding:12px;}
.tpm-card-footer{background:var(--tpm-card-footer-bg);padding:8px 12px;border-top:1px solid var(--tpm-border);font-size:12px;}
.tpm-grid-2{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;}
.tpm-grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;}
.tpm-grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;}
.tpm-text-small{font-size:11px;color:var(--tpm-text-soft);}
.tpm-text-normal{font-size:13px;color:var(--tpm-text);}
.tpm-text-large{font-size:16px;font-weight:600;color:var(--tpm-text-strong);}
.tpm-text-success{color:var(--tpm-success);}
.tpm-text-info{color:var(--tpm-info);}
.tpm-text-warning{color:var(--tpm-warning);}
.tpm-text-danger{color:var(--tpm-danger);}
.tpm-link{color:var(--tpm-link);text-decoration:none;font-size:13px;}
.tpm-link:hover{color:var(--tpm-success);text-decoration:underline;}
.tpm-button{background:var(--tpm-button-bg);color:var(--tpm-text-strong);border:none;padding:5px 10px;border-radius:4px;cursor:pointer;font-size:12px;font-weight:500;}
.tpm-button:hover{background:var(--tpm-button-hover);}
.tpm-button-sm{padding:3px 6px;font-size:11px;}
.tpm-button-icon{background:var(--tpm-button-bg);color:var(--tpm-text-strong);padding:5px;border-radius:4px;text-decoration:none;font-size:12px;cursor:pointer;}
.tpm-button-icon:hover{background:var(--tpm-button-hover);}
.tpm-badge{display:inline-block;padding:3px 8px;border-radius:12px;font-size:11px;font-weight:600;}
.tpm-table{width:100%;border-collapse:collapse;color:var(--tpm-text-strong);background:var(--tpm-table-bg);border-radius:8px;overflow:hidden;font-size:13px;}
.tpm-table th{background:var(--tpm-table-header-bg);padding:10px 8px;font-size:12px;color:var(--tpm-info);text-align:left;border-bottom:2px solid var(--tpm-border);}
.tpm-table td{padding:8px;border-bottom:1px solid var(--tpm-border);}
.tpm-table tr:last-child td{border-bottom:none;}
.tpm-nav{display:flex;justify-content:center;align-items:center;gap:12px;background:var(--tpm-nav-bg);padding:6px 16px;border-radius:40px;margin:10px 0;}
.tpm-nav-item{background:var(--tpm-nav-item-bg);color:var(--tpm-text-strong);border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;}
.tpm-nav-item:hover{background:var(--tpm-nav-item-hover);}
.tpm-nav-divider{width:1px;height:20px;background:var(--tpm-divider);}
.tpm-mb-1{margin-bottom:5px;}
.tpm-mb-2{margin-bottom:10px;}
.tpm-mb-3{margin-bottom:15px;}
.tpm-mb-4{margin-bottom:20px;}
.tpm-mt-1{margin-top:5px;}
.tpm-mt-2{margin-top:10px;}
.tpm-mt-3{margin-top:15px;}
.tpm-flex{display:flex;}
.tpm-flex-between{display:flex;justify-content:space-between;align-items:center;}
.tpm-gap-1{gap:5px;}
.tpm-gap-2{gap:10px;}
.tpm-update-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;display:flex;align-items:center;justify-content:center;}
.tpm-update-modal{width:500px;background:var(--tpm-card-bg);border:1px solid var(--tpm-info);border-radius:12px;padding:20px;color:var(--tpm-text);font-size:13px;}
.tpm-update-title{font-size:18px;font-weight:700;color:var(--tpm-info);margin-bottom:15px;}
.tpm-notification-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:100000;display:flex;align-items:center;justify-content:center;}
.tpm-notification-modal{width:400px;background:var(--tpm-card-bg);border:2px solid;border-radius:12px;padding:20px;color:var(--tpm-text);font-size:13px;box-shadow:0 0 20px rgba(0,0,0,0.5);}
.tpm-notification-title{font-size:16px;font-weight:600;margin-bottom:15px;padding-bottom:10px;border-bottom:1px solid var(--tpm-border);}
.tpm-notification-body{margin-bottom:20px;line-height:1.5;white-space:pre-line;}
.tpm-notification-close{text-align:right;}
.tpm-note-popup{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:750px;height:500px;background:var(--tpm-note-popup-bg);border:2px solid var(--tpm-info);border-radius:12px;padding:20px;z-index:100001;color:var(--tpm-text);box-shadow:0 0 20px rgba(0,0,0,0.5);display:flex;flex-direction:column;}
.tpm-note-popup textarea{width:100%;height:60px;background:var(--tpm-input-bg);color:var(--tpm-text-strong);border:1px solid var(--tpm-border);border-radius:4px;padding:6px;margin:5px 0;font-size:12px;resize:vertical;}
.tpm-note-item{padding:8px;margin-bottom:8px;border-radius:4px;font-size:11px;position:relative;line-height:1.4;}
.tpm-note-item.active{background:var(--tpm-note-item-active);border-left:3px solid var(--tpm-success);}
.tpm-note-item.inactive{background:var(--tpm-note-item-inactive);border-left:3px solid var(--tpm-text-soft);opacity:0.8;}
.tpm-note-date{color:var(--tpm-text-soft);font-size:9px;margin-top:4px;padding-top:2px;border-top:1px dotted var(--tpm-border);}
.tpm-note-remove{float:right;cursor:pointer;color:var(--tpm-note-remove);font-size:12px;padding:0 5px;}
.tpm-note-remove:hover{color:var(--tpm-note-remove-hover);}
.tpm-notes-book{display:flex;height:100%;gap:15px;}
.tpm-notes-sidebar{width:250px;border-right:1px solid var(--tpm-notes-sidebar-border);padding-right:15px;overflow-y:auto;}
.tpm-notes-page{flex:1;padding-left:15px;overflow-y:auto;position:relative;}
.tpm-notes-page::before{content:'';position:absolute;top:0;left:0;width:10px;height:100%;background:linear-gradient(to right, rgba(0,0,0,0.1), transparent);pointer-events:none;}
.tpm-notes-search{width:100%;padding:6px;margin-bottom:10px;background:var(--tpm-notes-search-bg);color:var(--tpm-text-strong);border:1px solid var(--tpm-border);border-radius:4px;font-size:12px;}
.tpm-notes-list{list-style:none;padding:0;margin:0;}
.tpm-notes-list li{padding:6px 8px;margin-bottom:2px;border-radius:4px;cursor:pointer;font-size:12px;display:flex;align-items:center;}
.tpm-notes-list li:hover{background:var(--tpm-notes-list-hover);}
.tpm-notes-list li.active{background:var(--tpm-notes-list-active);color:var(--tpm-success);}
.tpm-notes-list li.inactive{opacity:0.6;}
.tpm-notes-icon{font-size:12px;margin-right:6px;width:16px;text-align:center;}
.tpm-notes-name{flex:1;}
.tpm-notes-count{color:var(--tpm-text-soft);font-size:10px;margin-left:4px;}
.tpm-notes-total{font-size:11px;color:var(--tpm-text-soft);margin-top:10px;padding-top:5px;border-top:1px solid var(--tpm-notes-sidebar-border);}
.tpm-loading{display:flex;align-items:center;justify-content:center;padding:20px;gap:8px;}
.tpm-spinner{width:20px;height:20px;border:2px solid var(--tpm-border);border-top-color:var(--tpm-info);border-radius:50%;animation:spin 1s linear infinite;}
@keyframes spin{0%{transform:rotate(0deg);}100%{transform:rotate(360deg);}}
.tpm-filter-options{display:flex;gap:8px;margin:8px 0;font-size:11px;}
.tpm-filter-options label{cursor:pointer;}
.tpm-note-icon {cursor:pointer;font-size:12px;margin-right:4px;color:var(--tpm-text-soft);}
.tpm-note-icon:hover {color:var(--tpm-info);}
.tpm-active-indicator {font-size:10px;color:var(--tpm-success);margin-left:6px;font-weight:normal;}
.tpm-inactive-indicator {font-size:10px;color:var(--tpm-text-soft);margin-left:6px;font-weight:normal;}
.tpm-performer-card {background:var(--tpm-card-header-bg);border-radius:6px;padding:10px;border-left:3px solid;transition:all 0.2s;}
.tpm-performer-card:hover {background:var(--tpm-button-hover);}
.tpm-performer-rank {font-size:9px;color:var(--tpm-text-soft);margin-bottom:4px;}
.tpm-performer-name {font-size:13px;font-weight:600;display:flex;align-items:center;flex-wrap:wrap;gap:4px;margin-bottom:2px;}
.tpm-performer-stats {font-size:11px;display:flex;justify-content:space-between;margin-top:6px;padding-top:4px;border-top:1px dashed var(--tpm-border);}
.tpm-performer-value {color:var(--tpm-success);font-weight:600;}
.tpm-performer-days {color:var(--tpm-warning);}
.tpm-performer-rent {color:var(--tpm-info);}
.tpm-property-name {color:var(--tpm-text-soft);font-size:10px;margin-top:2px;}
.tpm-calculator {background:var(--tpm-calculator-bg);border:1px solid var(--tpm-calculator-border);border-radius:6px;padding:10px 16px;margin:4px 0;font-size:13px;}
.tpm-calculator > div {display:flex;justify-content:space-between;align-items:center;width:100%;gap:8px;}
.tpm-calculator-value {font-weight:600;color:var(--tpm-success);background:rgba(46,125,50,0.1);padding:4px 10px;border-radius:20px;display:inline-block;}
.tpm-history-section {margin-top:15px;border-top:1px solid var(--tpm-border);padding-top:10px;overflow-y:auto;}
.tpm-history-title {font-size:12px;color:var(--tpm-info);margin-bottom:8px;font-weight:600;position:sticky;top:0;background:var(--tpm-card-header-bg);padding:4px 0;}
.tpm-history-item {font-size:11px;padding:8px 4px;border-bottom:1px dotted var(--tpm-border);display:flex;flex-direction:column;gap:2px;}
.tpm-history-item:last-child {border-bottom:none;}
.tpm-history-main {display:flex;justify-content:space-between;}
.tpm-history-details {display:flex;justify-content:space-between;font-size:10px;color:var(--tpm-text-soft);}
.tpm-history-empty {font-size:11px;color:var(--tpm-text-soft);text-align:center;padding:8px;background:var(--tpm-card-bg);border-radius:4px;}
.tpm-market-suggestion {background:var(--tpm-card-bg);border-radius:4px;padding:6px 10px;margin:5px 0;font-size:11px;color:var(--tpm-text-soft);}
.tpm-loader-container {position:relative;display:inline-block;margin-left:10px;}
.tpm-inline-loader {display:inline-flex;align-items:center;gap:5px;padding:4px 8px;background:var(--tpm-card-bg);border-radius:4px;font-size:11px;}
.help-section, .help-section p, .help-section li, .help-section td, .help-section th { color: var(--tpm-text) !important; font-size:13px !important; }
.help-section a { color: var(--tpm-link) !important; }
.help-section code { color: var(--tpm-warning) !important; background: var(--tpm-input-bg); padding: 2px 5px; border-radius: 3px; }
.help-section h5 { color: var(--tpm-info) !important; font-size:15px !important; margin:15px 0 10px 0; }
</style>
`;
document.head.insertAdjacentHTML("beforeend", TPM_UI_STYLE);
function getStorageInfo() {
let totalBytes = 0;
let logCount = 0;
for (let key in localStorage) {
if (key.startsWith('tpm_')) totalBytes += localStorage[key].length * 2;
}
const store = getStore();
if (store.events) {
Object.values(store.events).forEach(events => {
logCount += events.length;
});
}
const pressure = (totalBytes / 1024 / CONFIG.STORAGE_LIMIT_KB) * 100;
return {
sizeKB: (totalBytes / 1024).toFixed(1),
logCount: logCount,
pressure: pressure,
retentionMonths: getRetentionMonths(pressure)
};
}
function encryptAPIKey(key) {
return btoa(key.split('').reverse().join(''));
}
function decryptAPIKey(encrypted) {
try {
const decrypted = atob(encrypted).split('').reverse().join('');
return /^[a-zA-Z0-9]{16}$/.test(decrypted) ? decrypted : '';
} catch (e) {
return '';
}
}
function exportData() {
const store = getStore();
const apiKey = localStorage.getItem('tpmApiKey');
const data = {
store: store,
autofill: safeJSON("tpm_property_autofill"),
prevExtensions: safeJSON("tpm_prev_extensions"),
tenantNotes: safeJSON("tpm_tenant_notes"),
rentHistory: safeJSON("tpm_rent_history"),
archive: tpmArchive,
apiKey: apiKey ? encryptAPIKey(apiKey) : '',
exportDate: new Date().toISOString(),
version: "3.0.0"
};
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tpm-backup-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
showNotification('Data exported successfully!', 'success');
}
function importData(jsonData) {
try {
const data = JSON.parse(jsonData);
if (data.store) localStorage.setItem('tpm_store_v2', JSON.stringify(data.store));
if (data.autofill) localStorage.setItem('tpm_property_autofill', JSON.stringify(data.autofill));
if (data.prevExtensions) localStorage.setItem('tpm_prev_extensions', JSON.stringify(data.prevExtensions));
if (data.tenantNotes) localStorage.setItem('tpm_tenant_notes', JSON.stringify(data.tenantNotes));
if (data.rentHistory) localStorage.setItem('tpm_rent_history', JSON.stringify(data.rentHistory));
if (data.archive) {
tpmArchive = data.archive;
localStorage.setItem('tpm_archive', JSON.stringify(data.archive));
}
if (data.apiKey) {
const decrypted = decryptAPIKey(data.apiKey);
if (decrypted) localStorage.setItem('tpmApiKey', decrypted);
}
showNotification('Data imported successfully! Reloading...', 'success');
setTimeout(() => location.reload(), 1500);
} catch (e) {
showNotification('Invalid backup file!', 'error');
}
}
function addTenantNote(tenantID, tenantName, note, isActive) {
const notes = safeJSON("tpm_tenant_notes");
if (!notes[tenantID]) notes[tenantID] = [];
notes[tenantID].push({
note: note.substring(0, 1000),
tenantName: tenantName,
timestamp: Date.now(),
formattedDate: new Date().toLocaleDateString(),
isActive: isActive
});
localStorage.setItem('tpm_tenant_notes', JSON.stringify(notes));
refreshTablesFromCache();
}
function removeTenantNote(tenantID, index) {
const notes = safeJSON("tpm_tenant_notes");
if (notes[tenantID] && notes[tenantID][index]) {
notes[tenantID].splice(index, 1);
if (notes[tenantID].length === 0) {
delete notes[tenantID];
}
localStorage.setItem('tpm_tenant_notes', JSON.stringify(notes));
refreshTablesFromCache();
}
}
function getTenantNotes(tenantID) {
const notes = safeJSON("tpm_tenant_notes");
return notes[tenantID] || [];
}
function trackRentChange(propertyID, oldRent, newRent, tenantID, tenantName) {
if (oldRent === newRent) return;
const history = safeJSON("tpm_rent_history");
if (!history[propertyID]) history[propertyID] = [];
history[propertyID].push({
oldRent: oldRent,
newRent: newRent,
tenantID: tenantID,
tenantName: tenantName,
timestamp: Date.now(),
formattedDate: new Date().toLocaleDateString()
});
localStorage.setItem('tpm_rent_history', JSON.stringify(history));
}
function getRentHistory(propertyID) {
const history = safeJSON("tpm_rent_history");
return history[propertyID] || [];
}
let activeLoadingCount = 0;
function showInlineLoader(container, message = 'Loading...') {
const loaderId = 'tpm-loader-' + Date.now();
const loader = document.createElement('div');
loader.id = loaderId;
loader.className = 'tpm-loader-container';
loader.innerHTML = `<div class="tpm-inline-loader"><div class="tpm-spinner" style="width:14px;height:14px;"></div><span>${message}</span></div>`;
container.appendChild(loader);
activeLoadingCount++;
return loaderId;
}
function hideInlineLoader(loaderId) {
const loader = document.getElementById(loaderId);
if (loader) loader.remove();
activeLoadingCount = Math.max(0, activeLoadingCount - 1);
}
async function rateLimitedFetch(url, retryCount = 0) {
const waitTime = checkRateLimit();
if (waitTime > 0) {
await new Promise(resolve => setTimeout(resolve, waitTime));
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(url, {
headers: {
'User-Agent': TPM_USER_AGENT
},
signal: controller.signal
});
clearTimeout(timeoutId);
updateRateLimitCounters();
const clonedResponse = response.clone();
try {
const data = await clonedResponse.json();
if (data.error) {
const code = data.error.code;
if (code === 5) {
console.error("TPM: Rate limit hit, backing off");
if (retryCount < CONFIG.MAX_RETRY_ATTEMPTS) {
showNotification(`ā ļø Rate limit hit, retrying (${retryCount + 1}/${CONFIG.MAX_RETRY_ATTEMPTS})...`, 'warning');
releaseTabLock(TAB_COORDINATION.PROPERTIES_LOCK);
await new Promise(resolve => setTimeout(resolve, 10000 * (retryCount + 1)));
return rateLimitedFetch(url, retryCount + 1);
} else {
showNotification('ā ļø Rate limit exceeded maximum retries. Please try again later.', 'error');
return null;
}
} else if (code === 2) {
showNotification('ā Invalid API key. Please check your key.', 'error');
return null;
} else if (code === 7 || code === 6) {
showNotification('ā Insufficient permissions. FULL ACCESS key required.', 'error');
return null;
} else {
console.error(`TPM: API error ${code}:`, data.error);
showNotification(`ā API error: ${data.error.error || 'Unknown error'}`, 'error');
return null;
}
}
} catch (e) {
}
return response;
} catch (error) {
clearTimeout(timeoutId);
console.error("TPM: Fetch error", error);
releaseTabLock(TAB_COORDINATION.PROPERTIES_LOCK);
if (retryCount < CONFIG.MAX_RETRY_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, 5000 * (retryCount + 1)));
return rateLimitedFetch(url, retryCount + 1);
}
throw error;
}
}
function handleApiError(data) {
if (!data || !data.error) return false;
const code = data.error.code;
if (code === 2) {
localStorage.removeItem('tpmApiKey');
showNotification("ā ļø Your Torn API key is no longer valid.\n\nIt may have been revoked or regenerated.\nYour stored key has been removed.\nPlease enter a new FULL ACCESS key in Settings.", 'warning');
return true;
}
if (code === 5) {
console.error("TPM: Rate limit detected in API error handler");
return true;
}
if (code === 7 || code === 6) {
showNotification("ā This script requires a FULL ACCESS API key to read user logs.", 'error');
return true;
}
return false;
}
async function getAllProperties(apiKey, forceOverride = false) {
const refreshBtn = document.getElementById('refreshBtn');
if (!acquireTabLock(TAB_COORDINATION.PROPERTIES_LOCK)) {
if (isDebugMode()) console.log('TPM: Another tab is fetching properties, waiting...');
const updated = await waitForTabUpdate(TAB_COORDINATION.PROPERTIES_LOCK);
if (updated) {
const cachedData = localStorage.getItem('prop_cache');
if (cachedData) {
if (isDebugMode()) console.log('TPM: Using properties from another tab');
return JSON.parse(cachedData);
}
}
releaseTabLock(TAB_COORDINATION.PROPERTIES_LOCK);
return null;
}
const loaderId = showInlineLoader(document.getElementById('torn-property-manager-root'), 'Fetching properties...');
const NOW = Date.now();
const COOLDOWN = 30 * 60 * 1000;
const OVERRIDE_DELAY = 60 * 1000;
const lastCall = parseInt(localStorage.getItem('prop_last_call')) || 0;
const cachedData = localStorage.getItem('prop_cache');
const waitTime = forceOverride ? OVERRIDE_DELAY : COOLDOWN;
if (lastCall !== 0) {
const timePassed = NOW - lastCall;
if (timePassed < waitTime) {
const remaining = Math.ceil((waitTime - timePassed) / 1000);
if (forceOverride) {
showNotification(`ā ļø Override cooldown active. Please wait ${remaining} more seconds.`, 'warning');
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.style.opacity = '0.5';
refreshBtn.style.cursor = 'not-allowed';
setTimeout(() => {
refreshBtn.disabled = false;
refreshBtn.style.opacity = '1';
refreshBtn.style.cursor = 'pointer';
}, waitTime - timePassed);
}
}
hideInlineLoader(loaderId);
releaseTabLock(TAB_COORDINATION.PROPERTIES_LOCK);
if (cachedData) return JSON.parse(cachedData);
throw new Error(`Rate limited. Please wait ${remaining}s.`);
}
}
let all = [], offset = 0, more = true;
try {
while (more) {
let res;
try {
res = await rateLimitedFetch(`https://api.torn.com/v2/user/properties?filters=ownedByUser&key=${apiKey}&offset=${offset}`);
if (!res) {
hideInlineLoader(loaderId);
releaseTabLock(TAB_COORDINATION.PROPERTIES_LOCK);
return cachedData ? JSON.parse(cachedData) : [];
}
} catch (fetchError) {
console.error("TPM: Fetch failed, skipping batch", fetchError);
break;
}
const data = await res.json();
if (handleApiError(data)) {
hideInlineLoader(loaderId);
releaseTabLock(TAB_COORDINATION.PROPERTIES_LOCK);
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.style.opacity = '1';
refreshBtn.style.cursor = 'pointer';
}
return cachedData ? JSON.parse(cachedData) : [];
}
if (data.error) throw new Error(data.error.error);
if (!data.properties) break;
const entries = Object.entries(data.properties);
all = all.concat(entries);
more = entries.length >= CONFIG.API_BATCH_SIZE;
offset += CONFIG.API_BATCH_SIZE;
if (more) await new Promise(resolve => setTimeout(resolve, 1000));
}
await initialiseTenantIntel();
const timestamp = Date.now();
localStorage.setItem('prop_last_call', timestamp);
localStorage.setItem('prop_cache', JSON.stringify(all));
tabChannel.postMessage({
type: 'PROPERTIES_UPDATED',
data: { properties: all, timestamp },
timestamp: timestamp
});
TAB_COORDINATION.lastUpdate = timestamp;
if (isDebugMode()) console.log(`TPM: Fetched ${all.length} properties`);
rotateLogsIfNeeded();
} finally {
hideInlineLoader(loaderId);
releaseTabLock(TAB_COORDINATION.PROPERTIES_LOCK);
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.style.opacity = '1';
refreshBtn.style.cursor = 'pointer';
}
}
return all;
}
async function initialiseTenantIntel() {
const apiKey = localStorage.getItem("tpmApiKey");
if (!apiKey) return;
const logs = await fetchLeaseLogs(apiKey);
processLeaseLogs(logs);
}
async function fetchLeaseLogs(apiKey) {
if (!acquireTabLock(TAB_COORDINATION.LOGS_LOCK)) {
if (isDebugMode()) console.log('TPM: Another tab is fetching logs, waiting...');
const updated = await waitForTabUpdate(TAB_COORDINATION.LOGS_LOCK);
if (updated) {
const cached = localStorage.getItem('tenant_logs_cache');
if (cached) {
if (isDebugMode()) console.log('TPM: Using logs from another tab');
const timestamp = Date.now();
localStorage.setItem('tenant_logs_last_call', timestamp);
TAB_COORDINATION.lastUpdate = timestamp;
return JSON.parse(cached);
}
}
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
return [];
}
const NOW = Date.now();
const COOLDOWN = 30 * 60 * 1000;
const lastCall = parseInt(localStorage.getItem('tenant_logs_last_call')) || 0;
const cached = localStorage.getItem('tenant_logs_cache');
if (NOW - lastCall < COOLDOWN && cached) {
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
return JSON.parse(cached);
}
return new Promise((resolve) => {
const waitTime = checkRateLimit();
if (waitTime > 0) {
setTimeout(() => {
makeLogRequest(apiKey, resolve);
}, waitTime);
} else {
makeLogRequest(apiKey, resolve);
}
});
}
function makeLogRequest(apiKey, resolve) {
let retryTimeout;
const timeoutId = setTimeout(() => {
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve([]);
}, 15000);
GM_xmlhttpRequest({
method: "GET",
url: `https://api.torn.com/user/?selections=log&log=5937,5939,5940,5943,5948,5951,5953&limit=1000&key=${apiKey}`,
headers: {
"Accept": "application/json",
"User-Agent": TPM_USER_AGENT
},
timeout: 15000,
onload: function(response) {
clearTimeout(timeoutId);
updateRateLimitCounters();
try {
const data = JSON.parse(response.responseText);
if (data.error && data.error.code === 5) {
console.error("TPM: Rate limit hit in log request");
if (RATE_LIMIT.retryCount < CONFIG.MAX_RETRY_ATTEMPTS) {
RATE_LIMIT.retryCount++;
showNotification(`ā ļø Rate limit hit, retrying (${RATE_LIMIT.retryCount}/${CONFIG.MAX_RETRY_ATTEMPTS})...`, 'warning');
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
retryTimeout = setTimeout(() => {
if (document.getElementById('torn-property-manager-root')) {
makeLogRequest(apiKey, resolve);
} else {
resolve([]);
}
}, 10000 * RATE_LIMIT.retryCount);
} else {
showNotification('ā ļø Rate limit exceeded maximum retries. Please try again later.', 'error');
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve([]);
}
return;
}
RATE_LIMIT.retryCount = 0;
if (data.error) {
const code = data.error.code;
if (code === 2) {
showNotification('ā Invalid API key. Please check your key.', 'error');
} else if (code === 7 || code === 6) {
showNotification('ā Insufficient permissions. FULL ACCESS key required.', 'error');
} else {
console.error("TPM: Log API error:", data.error);
}
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve([]);
return;
}
if (!data.log || typeof data.log !== 'object') {
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve([]);
return;
}
const logs = Object.values(data.log);
const timestamp = Date.now();
localStorage.setItem('tenant_logs_cache', JSON.stringify(logs));
localStorage.setItem('tenant_logs_last_call', timestamp);
tabChannel.postMessage({
type: 'LOGS_UPDATED',
data: { logs, timestamp },
timestamp: timestamp
});
TAB_COORDINATION.lastUpdate = timestamp;
if (isDebugMode()) console.log(`TPM: Fetched ${logs.length} lease events`);
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve(logs);
} catch(e) {
console.error("TPM: Log parse error", e);
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve([]);
}
},
onerror: () => {
clearTimeout(timeoutId);
console.error("TPM: Failed to fetch lease logs");
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve([]);
},
ontimeout: () => {
clearTimeout(timeoutId);
console.error("TPM: Log request timeout");
releaseTabLock(TAB_COORDINATION.LOGS_LOCK);
resolve([]);
}
});
window.addEventListener('beforeunload', () => {
if (retryTimeout) clearTimeout(retryTimeout);
clearTimeout(timeoutId);
});
}
async function verifyApiKey(key) {
const saveBtn = document.querySelector('.save-settings');
let retryTimeout;
let buttonTimeout;
if (!key || key.length !== 16 || !/^[a-zA-Z0-9]+$/.test(key)) {
showNotification("ā Invalid API key format. Must be 16 alphanumeric characters.", 'error');
return false;
}
if (!acquireTabLock(TAB_COORDINATION.VERIFY_LOCK)) {
showNotification("ā ļø Another tab is already verifying an API key. Please wait.", 'warning');
return false;
}
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
}
const cleanup = () => {
if (retryTimeout) clearTimeout(retryTimeout);
if (buttonTimeout) clearTimeout(buttonTimeout);
releaseTabLock(TAB_COORDINATION.VERIFY_LOCK);
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.style.opacity = '1';
saveBtn.style.cursor = 'pointer';
}
};
window.addEventListener('beforeunload', cleanup);
const timeoutId = setTimeout(() => {
cleanup();
window.removeEventListener('beforeunload', cleanup);
showNotification("ā API verification timed out.", 'error');
}, 15000);
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: `https://api.torn.com/user/?selections=log&limit=1&key=${key}`,
headers: {
"Accept": "application/json",
"User-Agent": TPM_USER_AGENT
},
anonymous: true,
timeout: 15000,
onload: (response) => {
clearTimeout(timeoutId);
updateRateLimitCounters();
try {
const data = JSON.parse(response.responseText);
if (data.error && data.error.code === 5) {
console.error("TPM: Rate limit hit during key verification");
if (RATE_LIMIT.retryCount < CONFIG.MAX_RETRY_ATTEMPTS) {
RATE_LIMIT.retryCount++;
showNotification(`ā ļø Rate limit hit, retrying (${RATE_LIMIT.retryCount}/${CONFIG.MAX_RETRY_ATTEMPTS})...`, 'warning');
if (saveBtn) {
buttonTimeout = setTimeout(() => {
saveBtn.disabled = false;
saveBtn.style.opacity = '1';
saveBtn.style.cursor = 'pointer';
}, 10000);
}
retryTimeout = setTimeout(() => {
window.removeEventListener('beforeunload', cleanup);
verifyApiKey(key).then(resolve);
}, 10000 * RATE_LIMIT.retryCount);
} else {
showNotification('ā ļø Rate limit exceeded maximum retries. Please try again later.', 'error');
cleanup();
window.removeEventListener('beforeunload', cleanup);
resolve(false);
}
return;
}
RATE_LIMIT.retryCount = 0;
cleanup();
window.removeEventListener('beforeunload', cleanup);
if (data.error) {
const code = data.error.code;
if (code === 2) showNotification("ā API key invalid or revoked.", 'error');
else if (code === 7 || code === 6) showNotification("ā This script requires a FULL ACCESS API key to read user logs.", 'error');
else showNotification(`ā Torn API error: ${data.error.error}`, 'error');
return resolve(false);
}
if (data.log !== undefined) {
showNotification("ā
API key verified successfully!", 'success');
return resolve(true);
}
showNotification("ā Unexpected API response.", 'error');
resolve(false);
} catch (e) {
console.error("TPM: JSON parse error:", e);
cleanup();
window.removeEventListener('beforeunload', cleanup);
resolve(false);
}
},
onerror: () => {
clearTimeout(timeoutId);
showNotification("ā Failed to reach Torn API.", 'error');
cleanup();
window.removeEventListener('beforeunload', cleanup);
resolve(false);
},
ontimeout: () => {
clearTimeout(timeoutId);
showNotification("ā API verification timed out.", 'error');
cleanup();
window.removeEventListener('beforeunload', cleanup);
resolve(false);
}
});
});
}
function processLeaseLogs(rawLogs) {
if (!rawLogs || !Array.isArray(rawLogs) || rawLogs.length === 0) return;
const processedLogs = safeJSON("tpm_processed_logs") || {};
let newProcessed = { ...processedLogs };
let newEvents = 0;
rawLogs.forEach(log => {
if (!log || !log.data) return;
const logId = log.id || `${log.timestamp}-${log.log}-${log.data.property_id}-${log.data.user_id}`;
if (processedLogs[logId]) return;
const propertyID = String(log.data.property_id || log.data.property || "unknown");
const tenantID = String(log.data.user_id || log.data.user || log.data.renter || "unknown");
const tenantName = log.data.user_name || "Unknown";
if (propertyID === "unknown" || tenantID === "unknown") return;
const totalPayment = typeof log.data.rent === 'number' ? log.data.rent : 0;
const days = typeof log.data.days === 'number' ? log.data.days : 0;
const dailyRent = (totalPayment > 0 && days > 0) ? Math.round(totalPayment / days) : 0;
if (tenantID.length > 5) {
storeTenantInfo(tenantID, tenantName);
}
const eventCode = EVENT_MAP[log.log] || 0;
const eventTime = log.timestamp * 1000;
addEventToStore(propertyID, tenantID, eventCode, dailyRent, days, totalPayment, eventTime);
newProcessed[logId] = true;
newEvents++;
if ((eventCode === EVENT_CODES.PROPERTY_RENTED || eventCode === EVENT_CODES.EXTENSION_ACCEPTED) && totalPayment > 0 && days > 0 && tenantID.length > 5) {
updateTenantStats(tenantID, totalPayment, days, true);
}
});
if (newEvents > 0) {
localStorage.setItem('tpm_processed_logs', JSON.stringify(newProcessed));
}
rotateLogsIfNeeded();
cleanupTenantDB();
}
function logLeaseEvent(propertyID, tenantID, tenantName, event, days, rent) {
const eventCode = EVENT_CODES[event.toUpperCase()] || 0;
addEventToStore(propertyID, tenantID, eventCode, rent, days, Date.now());
storeTenantInfo(tenantID, tenantName);
if (eventCode === EVENT_CODES.PROPERTY_RENTED || eventCode === EVENT_CODES.EXTENSION_ACCEPTED) {
updateTenantStats(tenantID, rent, days, true);
}
if (isDebugMode()) console.log(`TPM: Logged lease event: ${event} for property ${propertyID}`);
}
function cleanInvalidLogs(currentProperties) {
const store = getStore();
if (!store.events) return;
const cache = safeJSON("prop_cache");
const cacheIDs = cache.map(([id, p]) => String(p.id));
const liveIDs = currentProperties.map(p => String(p.id));
const validIDs = new Set([...liveIDs, ...cacheIDs]);
let removedCount = 0;
Object.keys(store.events).forEach(id => {
if (!validIDs.has(String(id))) {
if (store.events[id].length) {
updateArchiveFromEvents(store.events[id], 'Unknown');
}
delete store.events[id];
removedCount++;
tpmArchive.totalPropertiesArchived++;
}
});
if (removedCount > 0) {
saveStore(store);
localStorage.setItem('tpm_archive', JSON.stringify(tpmArchive));
if (isDebugMode()) console.log(`TPM: Archived logs for ${removedCount} sold properties`);
}
}
function getLastUpdateTimeUTC() {
const lastCall = localStorage.getItem('prop_last_call');
if (!lastCall) return "Never";
const date = new Date(parseInt(lastCall));
return date.toLocaleString('en-US', {
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC'
}) + ' UTC';
}
function FormatCurrency(value) {
if (value === 0) return "$0";
const abs = Math.abs(value);
if (abs < 1_000_000) return "$" + value.toLocaleString();
const units = ["M", "B", "T", "Qa", "Qi"];
let num = value / 1_000_000;
let unitIndex = 0;
while (Math.abs(num) >= 1000 && unitIndex < units.length - 1) {
num /= 1000;
unitIndex++;
}
return "$" + num.toFixed(1).replace(/\.0$/, "") + units[unitIndex];
}
function getCurrentPropertyID() {
const match = location.hash.match(/[Ii][Dd]=(\d+)/);
return match ? match[1] : null;
}
function getPropertyAutofill(propertyId) {
const stored = localStorage.getItem('tpm_property_autofill');
if (!stored) return null;
try {
const parsed = JSON.parse(stored);
return parsed[propertyId] || null;
} catch (e) {
console.warn("TPM: Autofill data corrupted");
return null;
}
}
function getRecentDecline(propertyID) {
const store = getStore();
if (!store.events?.[propertyID]) return null;
const declines = store.events[propertyID].filter(e => e[1] === EVENT_CODES.OFFER_DECLINED).sort((a,b) => b[0] - a[0]);
if (!declines.length) return null;
const hours = (Date.now() - declines[0][0]) / 3600000;
return hours < 24 ? "recent" : "old";
}
function getTenantStats(tenantID) {
const store = getStore();
let totalIncome = 0, totalLeases = 0, totalDays = 0;
if (store.events) {
Object.values(store.events).forEach(events => {
events.forEach(e => {
if (e[5] === tenantID) {
totalIncome += e[4] || 0;
totalDays += e[3] || 0;
totalLeases++;
}
});
});
}
return {
income: totalIncome,
leases: totalLeases,
days: totalDays,
avgLease: totalLeases ? Math.round(totalDays / totalLeases) : 0
};
}
function calculateTenantReliability(tenantID) {
const store = getStore();
return store.tenants?.[tenantID]?.s || 50;
}
function getRowColor(p, customs) {
if (p.status === "none") return STYLES.statusColors.expired;
if (p.extension) return STYLES.statusColors.offered;
if (p.offerask) return STYLES.statusColors.offerask;
if (p.status === "for_sale") return STYLES.statusColors.forsale;
if (p.status === "for_rent") return STYLES.statusColors.forrent;
if (p.status === "rented") {
if (p.daysLeft <= 0) return STYLES.statusColors.expired;
if (p.daysLeft <= customs.warn_lease_ex) return STYLES.statusColors.warning;
if (p.daysLeft <= customs.warn_lease_wa) return STYLES.statusColors.gettingclose;
return STYLES.statusColors.allgood;
}
return STYLES.statusColors.allgood;
}
function buildStatusDisplay(p, customs) {
let display = '';
if (p.status === "for_rent") display += 'š';
else if (p.status === "for_sale") display += 'š¢';
else if (p.status === "in_use") display += 'š';
if (p.daysLeft <= 0 && p.status !== "in_use" && !p.offerask) display += 'š“';
else if (p.status === "rented") {
if (p.daysLeft <= customs.warn_lease_ex && p.daysLeft > 0) display += 'ā ļø';
else if (p.daysLeft <= customs.warn_lease_wa && p.daysLeft > customs.warn_lease_ex) display += 'š”';
else if (p.daysLeft > customs.warn_lease_wa) display += 'š¢';
}
if (p.offerask) {
display += 'šš';
p.renterid = p.offerask.id;
p.rentername = p.offerask.name;
}
if (p.extension && p.extension.period) {
display += `šā${p.extension.period}`;
const declineStatus = getRecentDecline(p.id);
if (declineStatus === "recent") display += "ā±ļøā";
else if (declineStatus === "old") display += "ā";
}
if (!display) display = STATUS_DISPLAY[p.status] || "ā¢ļø Error";
return display;
}
async function loadProperties() {
const apiKey = localStorage.getItem('tpmApiKey');
if (!apiKey) {
renderSettings("ā ļø Please set your API key.");
showNotification("ā ļø Please set your API key in Settings.", 'warning');
const container = document.getElementById('settings-content');
if (container) {
container.style.display = 'block';
container.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
const loaderId = showInlineLoader(document.getElementById('torn-property-manager-root'), 'Loading properties...');
try {
const raw = await getAllProperties(apiKey);
if (!raw || !Array.isArray(raw)) return;
const customs = getCustoms();
let properties = raw.map(([id, p]) => {
const oldData = safeJSON("tpm_property_autofill")[p.id];
if (oldData && oldData.rent !== p.cost_per_day && p.cost_per_day > 0) {
trackRentChange(p.id, oldData.rent, p.cost_per_day, p.rented_by?.id, p.rented_by?.name);
}
let buttonValue = "Lease", renew = `https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=lease`;
if (p.status === "rented") {
buttonValue = "Renew";
renew = `https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=offerExtension`;
} else if (p.status === "in_use") {
buttonValue = "Owner";
renew = "#";
}
return {
id: p.id,
name: p.property.name,
status: p.status,
happy: p.status === "in_use" ? "ā" : p.happy,
upkeep: p.status === "in_use" ? "ā" : FormatCurrency(p.upkeep.property + (p.upkeep.staff || 0)),
staffupkeep: p.upkeep.staff || 0,
ownerid: p.owner.id,
rentername: p.status === "rented" ? p.rented_by.name : (p.status === "in_use" ? "š¤ Owner" : "Empty"),
renterid: p.status === "rented" ? p.rented_by.id : (p.status === "in_use" ? p.owner.id : p.owner.id),
daysLeft: p.status === "in_use" ? "ā" : (p.rental_period_remaining || 0),
lease: p.status === "in_use" ? "ā" : (p.rental_period || 0),
rent: p.status === "in_use" ? "ā" : (p.cost_per_day || 0),
extension: p.lease_extension,
offerask: p.renter_asked,
renew: renew,
buttonValue: buttonValue,
market_price: p.market_price || 1000000
};
});
const existing = JSON.parse(localStorage.getItem('tpm_property_autofill') || "{}");
const updatedAutofill = Object.fromEntries(properties.map(p => {
const prev = existing[p.id] || {};
return [p.id, {
rent: p.rent > 0 && p.rent !== "ā" ? p.rent : (prev.rent || 0),
lease: p.lease > 0 && p.lease !== "ā" ? p.lease : (prev.lease || 0),
type: p.name,
status: p.status,
market_price: p.market_price || prev.market_price || 1000000
}];
}));
localStorage.setItem('tpm_property_autofill', JSON.stringify(updatedAutofill));
cleanInvalidLogs(properties);
properties.sort((a, b) => (a.daysLeft === "ā" ? 999 : a.daysLeft) - (b.daysLeft === "ā" ? 999 : b.daysLeft));
renderAlerts(properties, customs);
renderTable(properties, customs);
renderSettings();
} finally {
hideInlineLoader(loaderId);
}
}
function refreshTablesFromCache() {
const propertiesContainer = document.getElementById('prop-content');
if (propertiesContainer && propertiesContainer.style.display === 'block') {
loadProperties();
}
const intelContainer = document.getElementById('tenantintel-content');
if (intelContainer && intelContainer.style.display === 'block') {
renderTenantIntel();
}
}
function renderAlerts(properties, customs) {
let expired = 0, warning = 0, close = 0, isOffered = 0, isOwned = 0;
properties.forEach(p => {
isOwned++;
if (p.status === "in_use") return;
if (["rented", "none", "for_rent", "for_sale"].includes(p.status)) {
if (p.daysLeft <= 0) expired++;
else if (p.daysLeft <= customs.warn_lease_ex) warning++;
else if (p.daysLeft <= customs.warn_lease_wa) close++;
}
if (p.extension || p.offerask) isOffered++;
});
let alertDiv = document.getElementById('alert-summary');
if (!alertDiv) {
const container = document.querySelector('#properties-page-wrap div');
if (!container) return;
alertDiv = document.createElement('div');
alertDiv.id = 'alert-summary';
container.insertBefore(alertDiv, container.children[1]);
}
alertDiv.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-size:12px; color:var(--tpm-info);">šļø Property Manager <span style="color:var(--tpm-text-soft); font-size:11px;">v3.0.0</span></span>
<span style="font-size:11px; color:var(--tpm-text-soft);">Updated: ${getLastUpdateTimeUTC()}</span>
</div>
<div class="tpm-stats-bar">
<div class="tpm-stat"><span class="tpm-stat-label">šļø Owned</span><span class="tpm-stat-value">${isOwned}</span></div>
<div class="tpm-stat danger"><span class="tpm-stat-label">š“ Expired/Empty</span><span class="tpm-stat-value">${expired}</span></div>
<div class="tpm-stat alert"><span class="tpm-stat-label">ā ļø Attn ā¤${customs.warn_lease_ex}d</span><span class="tpm-stat-value">${warning}</span></div>
<div class="tpm-stat warn"><span class="tpm-stat-label">š” Warn ā¤${customs.warn_lease_wa}d</span><span class="tpm-stat-value">${close}</span></div>
<div class="tpm-stat"><span class="tpm-stat-label">š Offers</span><span class="tpm-stat-value">${isOffered}</span></div>
</div>
`;
}
let currentPage = 1;
let searchTerm = "";
function renderTable(properties, customs) {
let container = document.getElementById('prop-content');
if (container) {
const closeBtn = container.querySelector('.tpm-panel-close');
container.innerHTML = '';
if (closeBtn) container.appendChild(closeBtn);
}
const filtered = properties.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()) || p.rentername.toLowerCase().includes(searchTerm.toLowerCase()));
const itemsPerPage = customs.items_per_page;
const totalPages = Math.ceil(filtered.length / itemsPerPage) || 1;
if (currentPage > totalPages) currentPage = 1;
const start = (currentPage - 1) * itemsPerPage;
const paginatedItems = filtered.slice(start, start + itemsPerPage);
container = document.getElementById('prop-content');
if (!container) return;
const getNavHtml = (idx) => `
<div class="tpm-nav">
<div class="tpm-flex tpm-gap-2">
<button class="tpm-nav-item btn-prev" ${currentPage === 1 ? 'disabled' : ''}>ā</button>
<span style="font-size:13px; color:var(--tpm-text-strong); min-width:70px; text-align:center;">${currentPage}/${totalPages}</span>
<button class="tpm-nav-item btn-next" ${currentPage === totalPages ? 'disabled' : ''}>ā¶</button>
</div>
<div class="tpm-nav-divider"></div>
<input type="text" class="prop-search-bar" data-idx="${idx}" placeholder="š Search..." style="background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px 8px; border-radius:4px; font-size:12px; width:140px;" value="${searchTerm}">
</div>
`;
container.insertAdjacentHTML('beforeend', getNavHtml(0));
container.insertAdjacentHTML('beforeend', `
<table class="tpm-table">
<thead>
<tr>
<th style="padding:8px;">šļø Property</th>
<th style="padding:8px; text-align:right;">š Happy</th>
<th style="padding:8px; text-align:right;">šµ Upkeep</th>
<th style="padding:8px;">š Status</th>
<th style="padding:8px;">š¤ Occupant</th>
<th style="padding:8px; text-align:right;">āļø Lease</th>
<th style="padding:8px; text-align:right;">š° Rent</th>
<th style="padding:8px; text-align:center;">š¬ Action</th>
</tr>
</thead>
<tbody id="prop-body"></tbody>
</table>
`);
const body = document.getElementById('prop-body');
if (!body) return;
body.innerHTML = '';
paginatedItems.forEach((p, index) => {
const row = document.createElement('tr');
row.style.backgroundColor = p.status === "in_use" ? "var(--tpm-card-bg)" : getRowColor(p, customs);
const reliabilityScore = p.status === "rented" ? calculateTenantReliability(p.renterid) : 0;
row.onmouseenter = () => { if (p.status !== "in_use") row.style.backgroundColor = STYLES.statusColors.hover; };
row.onmouseleave = () => { row.style.backgroundColor = p.status === "in_use" ? "var(--tpm-card-bg)" : getRowColor(p, customs); };
const displayStatus = buildStatusDisplay(p, customs);
const showIcon = p.renterid && p.renterid.toString().length > 5 && p.status === 'rented';
const hasNotes = showIcon && getTenantNotes(p.renterid).length > 0;
const noteIcon = showIcon ? `<span style="cursor:pointer; font-size:12px; color:var(--tpm-text-soft); margin-right:4px;" class="tpm-note-icon" data-tenant="${p.renterid}" data-name="${p.rentername}" data-active="${p.status === 'rented'}">${hasNotes ? 'š' : 'š'}</span>` : '';
row.innerHTML = `
<td style="padding:8px;">
<a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=customize" target="${getLinkTarget()}" style="color:var(--tpm-link); text-decoration:none; font-size:13px; font-weight:600;">${p.name}</a>
<div style="font-size:10px; color:var(--tpm-text-soft);">ID: ${p.id}</div>
</td>
<td style="padding:8px; text-align:right; font-size:13px; color:var(--tpm-success);">${p.happy}</td>
<td style="padding:8px; text-align:right; font-size:13px; color:var(--tpm-success);">${p.upkeep}</td>
<td style="padding:8px; font-size:13px; color:var(--tpm-text);">${displayStatus}</td>
<td style="padding:8px; font-size:13px;">
${noteIcon}<a href="https://www.torn.com/profiles.php?XID=${p.renterid}" target="${getLinkTarget()}" style="color:var(--tpm-link); text-decoration:none;">${p.rentername}</a>
</td>
<td style="padding:8px; text-align:right;">
<span style="font-size:13px; color:var(--tpm-warning);">${p.daysLeft}</span>
<div style="font-size:10px; color:var(--tpm-text-soft);">of ${p.lease}</div>
</td>
<td style="padding:8px; text-align:right; font-size:13px; color:var(--tpm-success);">${typeof p.rent === 'number' ? FormatCurrency(p.rent) : p.rent}</td>
<td style="padding:8px; text-align:center;">
${p.renew === "#" ? `<span style="color:var(--tpm-text-soft); font-size:11px;">${p.buttonValue}</span>` : `<a href="${p.renew}" target="${getLinkTarget()}" class="tpm-button tpm-button-sm">${p.buttonValue}</a>`}
</td>
`;
body.appendChild(row);
});
if (filtered.length > customs.propnavbar && customs.items_per_page >= customs.propnavbar) container.insertAdjacentHTML('beforeend', getNavHtml(1));
container.querySelectorAll('.prop-search-bar').forEach(input => {
input.oninput = (e) => {
const targetIdx = e.target.getAttribute('data-idx');
const cursor = e.target.selectionStart;
searchTerm = e.target.value;
currentPage = 1;
renderTable(properties, customs);
let nextInput = document.querySelector(`.prop-search-bar[data-idx="${targetIdx}"]`);
if (!nextInput) nextInput = document.querySelector('.prop-search-bar[data-idx="0"]');
if (nextInput) {
nextInput.focus();
nextInput.setSelectionRange(cursor, cursor);
}
};
});
container.querySelectorAll('.btn-prev').forEach(btn => { btn.onclick = () => { if (currentPage > 1) { currentPage--; renderTable(properties, customs); } }; });
container.querySelectorAll('.btn-next').forEach(btn => { btn.onclick = () => { if (currentPage < totalPages) { currentPage++; renderTable(properties, customs); } }; });
container.querySelectorAll('.tpm-note-icon').forEach(icon => {
icon.onclick = (e) => {
e.stopPropagation();
const tenant = icon.dataset.tenant;
const name = icon.dataset.name;
const active = icon.dataset.active === 'true';
showNotesBook(tenant, name, active);
};
});
}
function handleCollapsibleClick(e) {
const header = e.target.closest('.tpm-collapsible-header');
if (!header) return;
e.preventDefault();
e.stopPropagation();
const content = header.nextElementSibling;
if (!content) return;
const isOpen = content.style.display === 'block';
content.style.display = isOpen ? 'none' : 'block';
}
function initCollapsibleListeners() {
const container = document.getElementById('torn-property-manager-root');
if (!container) return;
container.addEventListener('click', handleCollapsibleClick);
}
function renderTenantIntel() {
const container = document.getElementById("tenantintel-content");
if (!container) return;
const loaderId = showInlineLoader(container, 'Loading tenant intelligence...');
container.innerHTML = "";
try {
const store = getStore();
const properties = JSON.parse(localStorage.getItem('prop_cache') || "[]");
const autofill = JSON.parse(localStorage.getItem('tpm_property_autofill') || "{}");
const tenantNotes = safeJSON("tpm_tenant_notes");
const rentHistory = safeJSON("tpm_rent_history");
const customs = getCustoms();
const historyLimit = customs.historyLimit || 10;
if (!properties.length) {
container.innerHTML = `<div style="padding:20px; text-align:center; color:var(--tpm-text-soft); font-size:13px;">No property data available. Please refresh.</div>`;
return;
}
if (isDebugMode()) {
console.log("TPM: Store events:", Object.keys(store.events || {}).length);
console.log("TPM: Properties:", properties.length);
console.log("TPM: Autofill data:", Object.keys(autofill).length);
}
const propertyDetails = {};
const marketData = {};
let totalRented = 0, totalRentable = 0, totalDailyIncome = 0, totalLifetimeIncome = tpmArchive.totalLifetimeIncome || 0, totalHappy = 0, totalLeaseDays = 0, totalProperties = 0, totalVacantLoss = 0, totalMarketValue = 0;
const incomeEvents = new Set([EVENT_CODES.PROPERTY_RENTED, EVENT_CODES.EXTENSION_ACCEPTED]);
const thisYear = new Date().getFullYear();
const lastYear = thisYear - 1;
let thisYearIncome = tpmArchive.yearlyIncome[thisYear] || 0, lastYearIncome = tpmArchive.yearlyIncome[lastYear] || 0;
properties.forEach(([id, p]) => {
if (p.status === "in_use") return;
if (!marketData[p.property?.name]) {
marketData[p.property?.name] = { rents: [], count: 0, rentedCount: 0, totalIncome: 0, totalMarketValue: 0 };
}
if (p.status === "rented" && p.cost_per_day > 0) {
marketData[p.property?.name].rents.push(p.cost_per_day);
marketData[p.property?.name].rentedCount++;
}
marketData[p.property?.name].count++;
});
properties.forEach(([id, p]) => {
if (p.status === "in_use") return;
const propID = String(p.id);
totalProperties++;
const marketPrice = autofill[propID]?.market_price || (p.market_price || 1000000);
totalMarketValue += marketPrice;
const currentTenant = p.status === "rented" ? {
id: p.rented_by?.id,
name: p.rented_by?.name,
daysLeft: p.rental_period_remaining || 0,
leaseLength: p.rental_period || 0,
rent: p.cost_per_day || 0,
propertyName: p.property?.name
} : null;
if (p.status === "rented" && p.cost_per_day > 0) {
totalDailyIncome += p.cost_per_day;
}
const isRentable = true;
if (isRentable) {
totalRentable++;
if (p.status === "rented") {
totalRented++;
totalHappy += p.happy || 0;
totalLeaseDays += p.rental_period || 0;
}
}
const propertyEvents = store.events?.[propID] || [];
let totalIncome = 0, totalLeaseCount = 0;
propertyEvents.forEach(e => {
const isIncomeEvent = incomeEvents.has(e[1]);
const eventIncome = isIncomeEvent ? (e[4] || 0) : 0;
if (isIncomeEvent) {
totalIncome += eventIncome;
totalLifetimeIncome += eventIncome;
totalLeaseCount++;
const eventYear = new Date(e[0]).getFullYear();
if (eventYear === thisYear) thisYearIncome += eventIncome;
if (eventYear === lastYear) lastYearIncome += eventIncome;
}
});
if (p.status === "none" || p.status === "for_rent") {
let estimatedRent = 0;
if (p.cost_per_day > 0) {
estimatedRent = p.cost_per_day;
} else if (autofill[propID]?.rent > 0) {
estimatedRent = autofill[propID].rent;
} else if (marketData[p.property?.name]?.rents.length > 0) {
const typeRents = marketData[p.property?.name].rents;
estimatedRent = Math.round(typeRents.reduce((a, b) => a + b, 0) / typeRents.length);
} else {
estimatedRent = 1000;
}
totalVacantLoss += estimatedRent * 30;
}
if (totalIncome === 0 && p.status === "rented" && autofill[propID]) {
const estRent = autofill[propID].rent || 0;
const estLease = autofill[propID].lease || 0;
if (estRent > 0 && estLease > 0) {
totalIncome = estRent * estLease;
totalLifetimeIncome += totalIncome;
totalLeaseCount = 1;
}
}
propertyDetails[propID] = {
id: propID,
name: p.property?.name || "Unknown",
type: p.property?.name || "Unknown",
status: p.status,
happy: p.happy || 0,
upkeep: (p.upkeep?.property || 0) + (p.upkeep?.staff || 0),
currentTenant: currentTenant,
rent: p.cost_per_day || 0,
leaseLength: p.rental_period || 0,
daysLeft: p.rental_period_remaining || 0,
extension: p.lease_extension,
offerask: p.renter_asked,
lastRent: autofill[propID]?.rent || 0,
lastLease: autofill[propID]?.lease || 0,
marketPrice: marketPrice,
lifetimeIncome: totalIncome,
totalLeases: totalLeaseCount,
hasLogs: propertyEvents.length > 0,
hasAutofill: !!autofill[propID],
rentHistory: rentHistory[propID] || []
};
marketData[p.property?.name].totalIncome += totalIncome;
marketData[p.property?.name].totalMarketValue += marketPrice;
});
const occupancyRate = totalRentable > 0 ? Math.round((totalRented / totalRentable) * 100) : 0;
const avgRent = totalRented ? Math.round(totalDailyIncome / totalRented) : 0;
const avgHappy = totalRented ? Math.round(totalHappy / totalRented) : 0;
const avgLease = totalRented ? Math.round(totalLeaseDays / totalRented) : 0;
const rentPerHappy = avgHappy ? Math.round(avgRent / avgHappy) : 0;
const portfolioROI = totalMarketValue > 0 ? Math.round((totalLifetimeIncome / totalMarketValue) * 100) : 0;
const growthPercent = lastYearIncome === 0 ? 100 : Math.round(((thisYearIncome - lastYearIncome) / lastYearIncome) * 100);
const tenantEntries = [];
Object.values(propertyDetails).forEach(p => {
if (p.currentTenant && p.status === "rented") {
tenantEntries.push({
tenantId: String(p.currentTenant.id),
tenantName: p.currentTenant.name,
propertyName: p.name,
propertyType: p.type,
leaseLength: p.currentTenant.leaseLength,
rent: p.currentTenant.rent,
lifetimeIncome: p.lifetimeIncome,
isActive: true
});
}
});
const topLongest = [...tenantEntries].sort((a,b) => b.leaseLength - a.leaseLength).slice(0,6);
const topValue = [...tenantEntries].sort((a,b) => b.lifetimeIncome - a.lifetimeIncome).slice(0,6);
const topPerformersId = `top-performers-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
const topPerformersHtml = `
<div class="tpm-card tpm-collapsible" data-section-id="${topPerformersId}" style="margin-bottom:12px;">
<div class="tpm-card-header tpm-flex-between tpm-collapsible-header" data-target="${topPerformersId}">
<span><span style="color:var(--tpm-info);">š Top Performers</span></span>
<span style="color:var(--tpm-text-soft); font-size:14px;" class="tpm-collapsible-arrow">ā¶</span>
</div>
<div id="${topPerformersId}" class="tpm-card-body tpm-collapsible-content" style="display:none;">
<div style="margin-bottom:20px;">
<div style="font-size:14px; color:var(--tpm-warning); margin-bottom:10px; font-weight:600;">š Longest Current Leases</div>
<div class="tpm-grid-3" style="gap:10px;">
${topLongest.map((t, i) => {
const score = calculateTenantReliability(t.tenantId);
const scoreColor = score >= 80 ? 'var(--tpm-success)' : score >= 60 ? 'var(--tpm-warning)' : score >= 40 ? 'var(--tpm-info)' : 'var(--tpm-danger)';
const hasNotes = getTenantNotes(t.tenantId).length > 0;
const activeStatus = `<span class="tpm-active-indicator">ā Active</span>`;
return `
<div class="tpm-performer-card" style="border-left-color:${scoreColor};">
<div class="tpm-performer-rank">#${i+1}</div>
<div class="tpm-performer-name">
<span class="tpm-note-icon" data-tenant="${t.tenantId}" data-name="${t.tenantName}" data-active="true">${hasNotes ? 'š' : 'š'}</span>
<a href="https://www.torn.com/profiles.php?XID=${t.tenantId}" target="${getLinkTarget()}" style="color:var(--tpm-link);">${t.tenantName}</a>
${activeStatus}
</div>
<div style="font-size:10px; color:var(--tpm-text-soft); margin:2px 0;">${t.propertyName}</div>
<div class="tpm-performer-stats">
<span class="tpm-performer-days">${t.leaseLength} days</span>
<span class="tpm-performer-value">${FormatCurrency(t.rent)}/d</span>
</div>
</div>
`;
}).join('')}
</div>
</div>
<div>
<div style="font-size:14px; color:var(--tpm-success); margin-bottom:10px; font-weight:600;">š° Top Lifetime Value (Current Tenants)</div>
<div class="tpm-grid-3" style="gap:10px;">
${topValue.map((t, i) => {
const score = calculateTenantReliability(t.tenantId);
const scoreColor = score >= 80 ? 'var(--tpm-success)' : score >= 60 ? 'var(--tpm-warning)' : score >= 40 ? 'var(--tpm-info)' : 'var(--tpm-danger)';
const hasNotes = getTenantNotes(t.tenantId).length > 0;
const activeStatus = `<span class="tpm-active-indicator">ā Active</span>`;
return `
<div class="tpm-performer-card" style="border-left-color:${scoreColor};">
<div class="tpm-performer-rank">#${i+1}</div>
<div class="tpm-performer-name">
<span class="tpm-note-icon" data-tenant="${t.tenantId}" data-name="${t.tenantName}" data-active="true">${hasNotes ? 'š' : 'š'}</span>
<a href="https://www.torn.com/profiles.php?XID=${t.tenantId}" target="${getLinkTarget()}" style="color:var(--tpm-link);">${t.tenantName}</a>
${activeStatus}
</div>
<div style="font-size:10px; color:var(--tpm-text-soft); margin:2px 0;">${t.propertyName}</div>
<div class="tpm-performer-stats">
<span class="tpm-performer-value">${FormatCurrency(t.lifetimeIncome)}</span>
<span style="color:var(--tpm-text-soft);">${FormatCurrency(t.rent)}/d</span>
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
</div>
`;
const financialHtml = `
<div class="tpm-mb-3" style="background:var(--tpm-card-header-bg); border-radius:8px; padding:12px;">
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:15px; margin-bottom:10px; text-align:center;">
<div><div style="font-size:11px; color:var(--tpm-success);">Daily Income</div><div style="font-size:16px; font-weight:600; color:var(--tpm-success);">${FormatCurrency(totalDailyIncome)}/day</div></div>
<div><div style="font-size:11px; color:var(--tpm-info);">Lifetime Income</div><div style="font-size:16px; font-weight:600; color:var(--tpm-info);">${FormatCurrency(totalLifetimeIncome)}</div></div>
<div><div style="font-size:11px; color:var(--tpm-warning);">Portfolio ROI</div><div style="font-size:16px; font-weight:600; color:var(--tpm-warning);">${portfolioROI}%</div></div>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:15px; text-align:center;">
<div><div style="font-size:11px; color:var(--tpm-info);">Avg Happy Rented</div><div style="font-size:16px; font-weight:600; color:var(--tpm-info);">${avgHappy}</div></div>
<div><div style="font-size:11px; color:var(--tpm-success);">Rent/Happy Rented</div><div style="font-size:16px; font-weight:600; color:var(--tpm-success);">${FormatCurrency(rentPerHappy)}</div></div>
<div><div style="font-size:11px; color:var(--tpm-info);">Avg Rent</div><div style="font-size:16px; font-weight:600; color:var(--tpm-info);">${FormatCurrency(avgRent)}/day</div></div>
</div>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:15px; padding-top:15px; border-top:1px solid var(--tpm-border); text-align:center;">
<div><div style="font-size:11px; color:var(--tpm-success);">Occupancy</div><div style="font-size:16px; font-weight:600; color:var(--tpm-success);">${occupancyRate}%</div></div>
<div><div style="font-size:11px; color:var(--tpm-danger);">Vacancy Loss</div><div style="font-size:16px; font-weight:600; color:var(--tpm-danger);">${FormatCurrency(totalVacantLoss)}/mo</div></div>
<div><div style="font-size:11px; color:var(--tpm-warning);">30d Projection</div><div style="font-size:16px; font-weight:600; color:var(--tpm-warning);">${FormatCurrency(totalDailyIncome * 30)}</div></div>
<div><div style="font-size:11px; color:var(--tpm-info);">90d Projection</div><div style="font-size:16px; font-weight:600; color:var(--tpm-info);">${FormatCurrency(totalDailyIncome * 90)}</div></div>
</div>
<div style="margin-top:10px; padding-top:10px; border-top:1px solid var(--tpm-border); display:flex; justify-content:space-around; text-align:center;">
<div><span style="color:var(--tpm-text-soft); font-size:11px;">š ${thisYear} Income</span><br><span style="color:var(--tpm-success); font-size:14px;">${FormatCurrency(thisYearIncome)}</span></div>
<div><span style="color:var(--tpm-text-soft); font-size:11px;">š ${lastYear} Income</span><br><span style="color:var(--tpm-warning); font-size:14px;">${FormatCurrency(lastYearIncome)}</span></div>
<div><span style="color:var(--tpm-text-soft); font-size:11px;">Growth</span><br><span style="color:${growthPercent >= 0 ? 'var(--tpm-success)' : 'var(--tpm-danger)'}; font-size:14px;">${growthPercent >= 0 ? '+' : ''}${growthPercent}%</span></div>
</div>
</div>
`;
const propertiesByType = {};
Object.values(propertyDetails).forEach(p => {
if (!propertiesByType[p.type]) propertiesByType[p.type] = [];
propertiesByType[p.type].push(p);
});
const propertiesHtml = Object.entries(propertiesByType).map(([type, props]) => {
const totalTypeIncome = props.reduce((sum, p) => sum + p.lifetimeIncome, 0);
const totalTypeMarketValue = props.reduce((sum, p) => sum + (p.marketPrice || 1000000), 0);
const typeROI = totalTypeMarketValue > 0 ? Math.round((totalTypeIncome / totalTypeMarketValue) * 100) : 0;
const sectionId = `type-${type.replace(/\s+/g, '-')}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
return `
<div class="tpm-card tpm-collapsible" data-section-id="${sectionId}" style="margin-bottom:12px;">
<div class="tpm-card-header tpm-flex-between tpm-collapsible-header" data-target="${sectionId}" style="display:grid; grid-template-columns:1fr auto auto; gap:15px; align-items:center;">
<span style="color:var(--tpm-info); font-weight:600;">${type}</span>
<span style="color:var(--tpm-text-soft); font-size:12px;">${props.length} properties</span>
<span style="color:var(--tpm-success); font-size:12px; font-weight:600;">ROI: ${typeROI}%</span>
<span style="color:var(--tpm-text-soft); font-size:14px;" class="tpm-collapsible-arrow">ā¶</span>
</div>
<div id="${sectionId}" class="tpm-card-body tpm-collapsible-content" style="display:none;">
${props.sort((a,b) => a.status === "rented" && b.status === "rented" ? a.daysLeft - b.daysLeft : 0).map(p => {
const statusIcon = buildStatusDisplay(p, customs);
const propertyROI = (p.marketPrice && p.marketPrice > 0) ? Math.round((p.lifetimeIncome / p.marketPrice) * 100) : 0;
const tenantNotesList = p.currentTenant ? getTenantNotes(p.currentTenant.id) : [];
const rentChanges = p.rentHistory.slice(-5).reverse();
const hasNotes = p.currentTenant && getTenantNotes(p.currentTenant.id).length > 0;
return `
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px; margin-bottom:8px;">
<div class="tpm-flex-between">
<div>
<a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=customize" target="${getLinkTarget()}" style="color:var(--tpm-link); text-decoration:none; font-size:14px; font-weight:600;">${p.name}</a>
<span style="font-size:11px; color:var(--tpm-text-soft); margin-left:6px;">ID: ${p.id}</span>
</div>
<span style="font-size:14px;">${statusIcon}</span>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr 1fr 1fr; gap:8px; margin:6px 0; font-size:12px;">
<div><span style="color:var(--tpm-text-soft);">Happy:</span> <span style="color:var(--tpm-success);">${p.happy}</span></div>
<div><span style="color:var(--tpm-text-soft);">Upkeep:</span> <span class="tpm-text-success">${FormatCurrency(p.upkeep)}</span></div>
<div><span style="color:var(--tpm-text-soft);">Rent:</span> <span class="tpm-text-success">${p.rent > 0 ? FormatCurrency(p.rent)+'/d' : (p.lastRent > 0 ? FormatCurrency(p.lastRent)+'*' : '-')}</span></div>
<div><span style="color:var(--tpm-text-soft);">Lease:</span> <span class="tpm-text-warning">${p.status === "rented" ? p.daysLeft+'d' : (p.lastLease ? p.lastLease+'d*' : '-')}</span></div>
<div><span style="color:var(--tpm-text-soft);">ROI:</span> <span class="tpm-text-info">${propertyROI}%</span></div>
</div>
<div class="tpm-flex-between" style="margin-bottom:4px;">
<div style="font-size:12px; display:flex; align-items:center; gap:4px; flex-wrap:wrap;">
${p.currentTenant ? `<span class="tpm-note-icon" data-tenant="${p.currentTenant.id}" data-name="${p.currentTenant.name}" data-active="true">${hasNotes ? 'š' : 'š'}</span> <a href="https://www.torn.com/profiles.php?XID=${p.currentTenant.id}" target="${getLinkTarget()}" style="color:var(--tpm-link);">${p.currentTenant.name}</a>` : '<span style="color:var(--tpm-text-soft);">Vacant</span>'}
</div>
<div style="font-size:12px; color:var(--tpm-info);">
Lifetime: ${FormatCurrency(p.lifetimeIncome)}
${p.lifetimeIncome === 0 && p.status === 'rented' ? ' (active)' : ''}
</div>
</div>
<div class="tpm-flex-between" style="margin-bottom:6px;">
<div class="tpm-flex tpm-gap-1">
<a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=customize" target="${getLinkTarget()}" class="tpm-button-icon" style="font-size:11px;">š </a>
${p.status === "rented" ? `<a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=offerExtension" target="${getLinkTarget()}" class="tpm-button-icon" style="font-size:11px;">ā©ļø</a>` : `<a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=lease" target="${getLinkTarget()}" class="tpm-button-icon" style="font-size:11px;">š</a>`}
</div>
${p.hasAutofill ? '<span style="font-size:10px; color:var(--tpm-text-soft);">History available</span>' : ''}
</div>
${rentChanges.length > 0 ? `
<details style="margin-top:4px;">
<summary style="font-size:10px; color:var(--tpm-info); cursor:pointer;">Rent Changes (${rentChanges.length})</summary>
<div style="margin-top:4px; max-height:100px; overflow-y:auto; background:var(--tpm-card-bg); padding:4px; border-radius:4px;">
${rentChanges.map(r => `
<div style="font-size:9px; padding:2px; border-bottom:1px solid var(--tpm-border);">
${r.formattedDate}: ${FormatCurrency(r.oldRent)} ā ${FormatCurrency(r.newRent)}/d
</div>
`).join('')}
</div>
</details>
` : ''}
${tenantNotesList.length > 0 ? `
<details style="margin-top:4px;">
<summary style="font-size:10px; color:var(--tpm-info); cursor:pointer;">Notes (${tenantNotesList.length})</summary>
<div style="margin-top:4px; max-height:100px; overflow-y:auto; background:var(--tpm-card-bg); padding:4px; border-radius:4px;">
${tenantNotesList.slice(-3).map(n => `
<div style="font-size:9px; padding:2px; border-bottom:1px solid var(--tpm-border);">
${n.formattedDate}: ${n.note}
</div>
`).join('')}
</div>
</details>
` : ''}
<div id="history-${p.id}" class="tpm-history-placeholder"></div>
</div>
`;
}).join('')}
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="tpm-panel-close" data-target="tenantintel-content">ā</div>
<div class="tpm-mb-2" style="display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--tpm-info); font-size:15px;">Portfolio Overview</span>
<span style="color:var(--tpm-text-soft); font-size:11px;">Total: ${totalProperties} (${totalRented}/${totalRentable} rented)</span>
</div>
${financialHtml}
${topPerformersHtml}
${propertiesHtml}
<div class="tpm-text-small tpm-text-center tpm-mt-2" style="color:var(--tpm-text-soft); padding:8px;">
* estimated from historical data Ā· Click headers to expand/collapse
</div>
`;
initCollapsibleListeners();
container.querySelectorAll('.tpm-note-icon').forEach(icon => {
icon.onclick = (e) => {
e.stopPropagation();
const tenant = icon.dataset.tenant;
const name = icon.dataset.name;
const active = icon.dataset.active === 'true';
showNotesBook(tenant, name, active);
};
});
Object.values(propertyDetails).forEach(p => {
const placeholder = document.getElementById(`history-${p.id}`);
if (placeholder) {
addLeaseHistory(placeholder, p.id);
}
});
} finally {
hideInlineLoader(loaderId);
}
}
function renderSettings(message = '') {
const container = document.getElementById('settings-content');
if (container) {
const closeBtn = container.querySelector('.tpm-panel-close');
container.innerHTML = '';
if (closeBtn) container.appendChild(closeBtn);
}
const customs = getCustoms();
const storage = getStorageInfo();
container.insertAdjacentHTML('beforeend', `
<div style="margin-top:15px; background:var(--tpm-card-bg); border-radius:8px; padding:16px;">
<div class="tpm-flex-between tpm-mb-2" style="border-bottom:1px solid var(--tpm-border); padding-bottom:8px;">
<h4 style="color:var(--tpm-info); margin:0; font-size:16px;">āļø Settings</h4>
${message ? `<span style="color:var(--tpm-danger); font-size:12px;">${message}</span>` : ''}
</div>
<div style="display:flex; flex-direction:column; gap:15px;">
<div style="background:var(--tpm-card-header-bg); padding:12px; border-radius:6px;">
<label style="display:block; color:var(--tpm-text-soft); font-size:12px; margin-bottom:4px;">API Key (Full Access)</label>
<input type="password" class="set-api" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:8px; border-radius:4px; font-size:13px;" value="${localStorage.getItem('tpmApiKey') || ''}">
<div class="tpm-text-small" style="margin-top:4px;">30-min cache, 60-sec override via refresh button Ā· Optimized storage keeps 5x more history</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">Properties Per Page</label>
<input type="number" class="set-ipp" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;" value="${customs.items_per_page}" min="5" max="50">
</div>
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">Nav Threshold</label>
<input type="number" class="set-secnav" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;" value="${customs.propnavbar}" min="5" max="50">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">ā ļø Attention (days)</label>
<input type="number" class="set-ex" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;" value="${customs.warn_lease_ex}" min="1" max="30">
</div>
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">š” Warning (days)</label>
<input type="number" class="set-wa" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;" value="${customs.warn_lease_wa}" min="1" max="60">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">History Display Limit</label>
<input type="number" class="set-history-limit" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;" value="${customs.historyLimit}" min="5" max="50">
</div>
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">Debug Mode</label>
<select class="set-debug-mode" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;">
<option value="false" ${localStorage.getItem('tpm_debug_mode') !== 'true' ? 'selected' : ''}>Disabled</option>
<option value="true" ${localStorage.getItem('tpm_debug_mode') === 'true' ? 'selected' : ''}>Enabled</option>
</select>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">Link Opening</label>
<select class="set-linktarget" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;">
<option value="new" ${(localStorage.getItem('tpm_link_target') || 'new') === 'new' ? 'selected' : ''}>New Tab</option>
<option value="same" ${localStorage.getItem('tpm_link_target') === 'same' ? 'selected' : ''}>Same Tab</option>
</select>
</div>
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">Auto-Fill Forms</label>
<select class="set-autofill-lease" style="width:100%; background:var(--tpm-input-bg); color:var(--tpm-text-strong); border:1px solid var(--tpm-border); padding:5px; border-radius:3px; font-size:13px;">
<option value="true" ${(localStorage.getItem('tpm_autofill_lease') ?? 'true') === 'true' ? 'selected' : ''}>Enabled</option>
<option value="false" ${localStorage.getItem('tpm_autofill_lease') === 'false' ? 'selected' : ''}>Disabled</option>
</select>
</div>
</div>
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<div style="color:var(--tpm-info); font-size:12px; margin-bottom:5px;">š¾ Storage Management</div>
<div style="display:flex; justify-content:space-between; font-size:11px; margin-bottom:5px;">
<span>Usage: ${storage.sizeKB} KB / 5000 KB</span>
<span style="color:${storage.pressure > 85 ? 'var(--tpm-danger)' : (storage.pressure > 60 ? 'var(--tpm-warning)' : 'var(--tpm-success)')};">${storage.pressure.toFixed(1)}%</span>
</div>
<div style="font-size:10px; color:var(--tpm-text-soft); margin-bottom:8px;">
Optimized storage: 70-80% smaller Ā· Keeping last ${storage.retentionMonths === 999 ? 'all' : storage.retentionMonths + ' months'} Ā· Archive: ${tpmArchive.totalPropertiesArchived} sold
</div>
</div>
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<div style="color:var(--tpm-info); font-size:12px; margin-bottom:5px;">š Storage Info</div>
<div style="display:flex; justify-content:space-between; font-size:11px;">
<span>Log events: ${storage.logCount}</span>
<span>Archive: ${FormatCurrency(tpmArchive.totalLifetimeIncome)}</span>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">š¤ Export Data</label>
<button class="tpm-button" id="exportDataBtn" style="width:100%; margin-top:5px;">Download Backup</button>
</div>
<div style="background:var(--tpm-card-header-bg); padding:10px; border-radius:6px;">
<label class="tpm-text-small" style="font-size:11px;">š„ Import Data</label>
<input type="file" id="importDataFile" accept=".json" style="display:none;">
<button class="tpm-button" id="importDataBtn" style="width:100%; margin-top:5px;">Restore Backup</button>
</div>
</div>
</div>
<div class="tpm-flex-between tpm-mt-2" style="border-top:1px solid var(--tpm-border); padding-top:12px;">
<button class="tpm-button" id="clearAllData" style="background:var(--tpm-danger-btn-bg); color:white; font-weight:bold;">šļø Clear All Data</button>
<button class="save-settings tpm-button" style="background:var(--tpm-success-btn-bg); color:white; font-weight:bold;">š¾ Save & Reload</button>
</div>
</div>
`);
document.getElementById('exportDataBtn')?.addEventListener('click', exportData);
document.getElementById('importDataBtn')?.addEventListener('click', () => document.getElementById('importDataFile').click());
document.getElementById('importDataFile')?.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => importData(event.target.result);
reader.readAsText(file);
}
});
document.getElementById('clearAllData')?.addEventListener('click', () => {
if (confirm('ā ļø Are you sure? This will clear ALL TPM stored data including API key and lease history.')) {
const keysToRemove = [];
for (let key in localStorage) {
if (key.startsWith('tpm_') || key === 'tpmApiKey' || key === 'prop_cache' || key === 'prop_last_call' || key === 'tenant_logs_cache' || key === 'tenant_logs_last_call') {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
showNotification('TPM data cleared. Reloading...', 'success');
setTimeout(() => location.reload(), 1500);
}
});
const saveBtn = document.querySelector('.save-settings');
if (saveBtn) {
saveBtn.onclick = async () => {
const apiInput = document.querySelector('.set-api');
if (!apiInput) return;
const apiKey = apiInput.value.trim();
if (!apiKey) {
showNotification('Please enter an API key', 'error');
return;
}
const isValid = await verifyApiKey(apiKey);
if (isValid) {
localStorage.setItem('tpmApiKey', apiKey);
showNotification('API key saved. Reloading...', 'success');
setTimeout(() => location.reload(), 1500);
}
};
}
}
function renderHelp() {
const container = document.getElementById('help-content');
if (container) {
const closeBtn = container.querySelector('.tpm-panel-close');
container.innerHTML = '';
if (closeBtn) container.appendChild(closeBtn);
}
const customs = getCustoms();
const storage = getStorageInfo();
container.insertAdjacentHTML('beforeend', `
<div class="help-section" style="margin-top:15px; background:var(--tpm-card-bg); border-radius:8px; padding:20px; max-height:70vh; overflow-y:auto;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; border-bottom:1px solid var(--tpm-border); padding-bottom:10px; position:sticky; top:0; background:var(--tpm-card-bg); z-index:10;">
<h4 style="color:var(--tpm-info); margin:0; font-size:20px; font-weight:600;">š Torn Property Manager Documentation</h4>
<span style="color:var(--tpm-text-soft); font-size:12px;">v3.0.0 | Sprinkers [4056515]</span>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin-bottom:20px;">
<div style="background:var(--tpm-card-header-bg); border-radius:6px; padding:10px; text-align:center;">
<div style="color:var(--tpm-text-soft); font-size:11px;">Storage Used</div>
<div style="color:var(--tpm-info); font-size:16px; font-weight:600;">${storage.sizeKB} KB</div>
<div style="color:${storage.pressure > 85 ? 'var(--tpm-danger)' : (storage.pressure > 60 ? 'var(--tpm-warning)' : 'var(--tpm-success)')}; font-size:11px;">${storage.pressure.toFixed(1)}%</div>
</div>
<div style="background:var(--tpm-card-header-bg); border-radius:6px; padding:10px; text-align:center;">
<div style="color:var(--tpm-text-soft); font-size:11px;">Log Events</div>
<div style="color:var(--tpm-info); font-size:16px; font-weight:600;">${storage.logCount}</div>
</div>
<div style="background:var(--tpm-card-header-bg); border-radius:6px; padding:10px; text-align:center;">
<div style="color:var(--tpm-text-soft); font-size:11px;">Retention</div>
<div style="color:var(--tpm-warning); font-size:16px; font-weight:600;">${storage.retentionMonths === 999 ? 'All' : storage.retentionMonths + ' months'}</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:20px; margin-bottom:20px;">
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px;">
<div style="color:var(--tpm-info); font-size:16px; font-weight:600; margin-bottom:15px; border-bottom:1px solid var(--tpm-border); padding-bottom:5px;">šļø Property Dashboard</div>
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:10px; font-size:12px;">
<div>
<div style="font-weight:600; margin-bottom:5px; color:var(--tpm-text-strong);">Status Colors</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-success);">š¢</span> Rented Safe</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-warning);">š”</span> Getting Close</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-alert);">ā ļø</span> Expiring Soon</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-danger);">š“</span> Expired/Empty</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-warning);">š</span> For Rent</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-info);">š¢</span> For Sale</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-text-soft);">š</span> Personal Use</div>
<div style="color:var(--tpm-text);"><span style="color:var(--tpm-info);">š</span> Offer/Extension</div>
</div>
<div>
<div style="font-weight:600; margin-bottom:5px; color:var(--tpm-text-strong);">Features</div>
<div>⢠Search by property/tenant</div>
<div>⢠Pagination (${customs.items_per_page} per page)</div>
<div>⢠Quick action buttons</div>
<div>⢠Hover highlighting</div>
<div>⢠Alert summary bar</div>
<div>⢠Last updated timestamp</div>
<div>⢠Notes icons (š/š)</div>
</div>
</div>
</div>
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px;">
<div style="color:var(--tpm-info); font-size:16px; font-weight:600; margin-bottom:15px; border-bottom:1px solid var(--tpm-border); padding-bottom:5px;">š Tenant Intelligence</div>
<div style="font-size:12px;">
<div style="font-weight:600; margin-bottom:5px; color:var(--tpm-text-strong);">Portfolio Metrics</div>
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:5px; margin-bottom:10px;">
<div>⢠Daily Income</div><div>⢠Lifetime Income</div>
<div>⢠Portfolio ROI %</div><div>⢠Occupancy Rate</div>
<div>⢠Vacancy Loss</div><div>⢠30/60/90d Projections</div>
<div>⢠Yearly Trends</div><div>⢠Growth %</div>
<div>⢠Avg Happy</div><div>⢠Rent/Happy Ratio</div>
</div>
<div style="font-weight:600; margin:10px 0 5px 0; color:var(--tpm-text-strong);">Top Performers</div>
<div>⢠Longest current leases</div>
<div>⢠Highest lifetime value</div>
<div>⢠Reliability scoring</div>
<div style="font-weight:600; margin:10px 0 5px 0; color:var(--tpm-text-strong);">Property Types</div>
<div>⢠Grouped by type with ROI %</div>
<div>⢠Individual property cards</div>
<div>⢠Complete lease history</div>
<div>⢠Rent change tracking</div>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:20px; margin-bottom:20px;">
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px;">
<div style="color:var(--tpm-info); font-size:16px; font-weight:600; margin-bottom:15px; border-bottom:1px solid var(--tpm-border); padding-bottom:5px;">š Lease/Renew Forms</div>
<div style="font-size:12px;">
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Auto-fill with last recorded values</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Live calculator (updates as you type)</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Market analysis with IQR filtering</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Records discovered warning</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Complete property history</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> System events (šļø Torn System)</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Unknown users (ID with profile link)</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Known users (name + notes icon)</div>
<div style="margin-bottom:8px;"><span style="color:var(--tpm-success);">ā</span> Escape key closes sections</div>
</div>
</div>
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px;">
<div style="color:var(--tpm-info); font-size:16px; font-weight:600; margin-bottom:15px; border-bottom:1px solid var(--tpm-border); padding-bottom:5px;">š Notes Book</div>
<div style="font-size:12px;">
<div style="margin-bottom:8px;">⢠š/š icons indicate notes status</div>
<div style="margin-bottom:8px;">⢠Click icon to open Notes Book</div>
<div style="margin-bottom:8px;">⢠Searchable tenant list</div>
<div style="margin-bottom:8px;">⢠Show/hide inactive tenants</div>
<div style="margin-bottom:8px;">⢠Add/edit notes with timestamps</div>
<div style="margin-bottom:8px;">⢠Active/former tenant indicators</div>
<div style="margin-bottom:8px;">⢠Clear all notes option</div>
<div style="margin-bottom:8px;">⢠Notes persist across sessions</div>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:20px; margin-bottom:20px;">
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px;">
<div style="color:var(--tpm-info); font-size:16px; font-weight:600; margin-bottom:15px; border-bottom:1px solid var(--tpm-border); padding-bottom:5px;">š¾ Storage Management</div>
<div style="font-size:12px;">
<div style="margin-bottom:8px;">⢠Optimized format (70-80% smaller)</div>
<div style="margin-bottom:8px;">⢠Auto-rotation at storage thresholds</div>
<div style="margin-bottom:8px;">⢠Archive preserves aggregates</div>
<div style="margin-bottom:8px;">⢠Export/Import full backup</div>
<div style="margin-bottom:8px;">⢠Pressure warnings at >90%</div>
<div style="margin-bottom:8px;">⢠Critical cleanup at >95%</div>
</div>
</div>
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px;">
<div style="color:var(--tpm-info); font-size:16px; font-weight:600; margin-bottom:15px; border-bottom:1px solid var(--tpm-border); padding-bottom:5px;">š API Integration</div>
<div style="font-size:12px;">
<div style="margin-bottom:8px;">⢠FULL ACCESS key required</div>
<div style="margin-bottom:8px;">⢠Rate limits: 50/min, 1000/hour</div>
<div style="margin-bottom:8px;">⢠30-min cache, 60-sec override</div>
<div style="margin-bottom:8px;">⢠Cross-tab synchronization</div>
<div style="margin-bottom:8px;">⢠Auto-retry with backoff</div>
<div style="margin-bottom:8px;">⢠Batch processing (100 items)</div>
</div>
</div>
</div>
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px; margin-bottom:20px;">
<div style="color:var(--tpm-info); font-size:16px; font-weight:600; margin-bottom:15px; border-bottom:1px solid var(--tpm-border); padding-bottom:5px;">āļø Settings</div>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:15px; font-size:12px;">
<div>
<div style="font-weight:600;">API Key</div>
<div>FULL ACCESS</div>
</div>
<div>
<div style="font-weight:600;">Properties Per Page</div>
<div>${customs.items_per_page}</div>
</div>
<div>
<div style="font-weight:600;">Nav Threshold</div>
<div>${customs.propnavbar}</div>
</div>
<div>
<div style="font-weight:600;">ā ļø Attention</div>
<div>${customs.warn_lease_ex} days</div>
</div>
<div>
<div style="font-weight:600;">š” Warning</div>
<div>${customs.warn_lease_wa} days</div>
</div>
<div>
<div style="font-weight:600;">History Limit</div>
<div>${customs.historyLimit}</div>
</div>
<div>
<div style="font-weight:600;">Link Opening</div>
<div>${localStorage.getItem('tpm_link_target') === 'same' ? 'Same Tab' : 'New Tab'}</div>
</div>
<div>
<div style="font-weight:600;">Auto-Fill</div>
<div>${isLeaseAutofillEnabled() ? 'Enabled' : 'Disabled'}</div>
</div>
<div>
<div style="font-weight:600;">Debug Mode</div>
<div>${isDebugMode() ? 'On' : 'Off'}</div>
</div>
</div>
</div>
<div style="background:var(--tpm-card-header-bg); border-radius:8px; padding:15px; text-align:center; font-size:11px; color:var(--tpm-text-soft);">
<p style="margin:2px 0;">š Refresh: 30-min cache, 60-sec override ⢠š Auto dark/light mode ⢠š Click headers to expand</p>
<p style="margin:2px 0;">š¦ All data stored locally ⢠ā±ļø Rate limits respected ⢠šļø Smart rotation at 60% capacity</p>
<p style="margin:10px 0 0 0; color:var(--tpm-info);">Author: Sprinkers [4056515]</p>
</div>
</div>
`);
}
let formProcessed = false;
let formFillTimeout = null;
function addMarketSuggestions(container, propertyType) {
const existing = container.parentNode ? container.parentNode.querySelectorAll('.tpm-market-suggestion') : [];
existing.forEach(el => el.remove());
const stored = JSON.parse(localStorage.getItem('tpm_property_autofill') || "{}");
const typeRecords = Object.values(stored).filter(v => v.type === propertyType && v.status === "rented" && v.rent > 0);
const suggestionDiv = document.createElement('div');
suggestionDiv.className = 'tpm-market-suggestion';
if (typeRecords.length === 0) {
suggestionDiv.innerHTML = `ā¹ļø No historical data for ${propertyType} yet. Enter your first rental to start building history.`;
} else {
let rents = typeRecords.map(v => v.rent).sort((a, b) => a - b);
const percentile = (arr, p) => {
const index = (arr.length - 1) * p;
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) return arr[index];
return Math.round(arr[lower] + (arr[upper] - arr[lower]) * (index - lower));
};
const q1 = percentile(rents, 0.25);
const q3 = percentile(rents, 0.75);
const iqr = q3 - q1;
const min = q1 - 1.5 * iqr;
const max = q3 + 1.5 * iqr;
const filtered = rents.filter(v => v >= min && v <= max);
if (filtered.length) rents = filtered;
const median = percentile(rents, 0.50);
const avg = Math.round(rents.reduce((a, b) => a + b, 0) / rents.length);
const low = percentile(rents, 0.25);
const high = percentile(rents, 0.75);
suggestionDiv.innerHTML = `š Market rates for ${propertyType}: Avg ${FormatCurrency(avg)}/d | Median ${FormatCurrency(median)}/d | Range ${FormatCurrency(low)}-${FormatCurrency(high)}/d`;
}
if (container.parentNode && container.parentNode.firstChild) {
container.parentNode.insertBefore(suggestionDiv, container);
} else {
container.insertAdjacentElement('beforebegin', suggestionDiv);
}
}
function addLiveCalculator(container, dailyRent, propertyId, days) {
const existing = container.parentNode ? container.parentNode.querySelectorAll('.tpm-calculator') : [];
existing.forEach(el => el.remove());
let costInput, daysInput;
if (container && container.classList) {
if (container.classList.contains('offerExtension-form')) {
costInput = container.querySelector('input[data-name="offercost"]');
daysInput = container.querySelector('input[data-name="days"]');
} else {
costInput = container.querySelector('input[data-name="money"]');
daysInput = container.querySelector('input[data-name="days"]');
}
}
if (!costInput || !daysInput) {
const form = document.querySelector('.offerExtension-form, .lease-form');
if (form && form !== container) {
return addLiveCalculator(form, dailyRent, propertyId, days);
}
return;
}
const calculatorDiv = document.createElement('div');
calculatorDiv.className = 'tpm-calculator';
const currentDays = parseInt(daysInput.value) || days || 0;
const currentDailyRent = dailyRent > 0 ? dailyRent : 0;
const currentTotal = currentDailyRent * currentDays;
calculatorDiv.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; width:100%;">
<span><span style="color:var(--tpm-text-soft);">Daily Rent:</span> <span id="tpm-calc-rent-${propertyId}" style="font-weight:600;">${FormatCurrency(currentDailyRent)}</span></span>
<span style="color:var(--tpm-text-soft);">Ć</span>
<span><span style="color:var(--tpm-text-soft);">Days:</span> <span id="tpm-calc-days-${propertyId}" style="font-weight:600;">${currentDays}</span></span>
<span style="color:var(--tpm-text-soft);">=</span>
<span><span style="color:var(--tpm-text-soft);">Total:</span> <span id="tpm-calc-total-${propertyId}" class="tpm-calculator-value">${FormatCurrency(currentTotal)}</span></span>
</div>
`;
if (container.parentNode) {
const warningDiv = container.querySelector('.tpm-autofill-warning');
if (warningDiv) {
warningDiv.insertAdjacentElement('afterend', calculatorDiv);
} else {
container.parentNode.insertBefore(calculatorDiv, container);
}
} else {
container.insertAdjacentElement('beforebegin', calculatorDiv);
}
function updateCalculator() {
const days = parseInt(daysInput.value) || 0;
const currentRent = dailyRent > 0 ? dailyRent : (parseFloat(costInput.value) / (days || 1)) || 0;
const total = currentRent * days;
const rentSpan = document.getElementById(`tpm-calc-rent-${propertyId}`);
const daysSpan = document.getElementById(`tpm-calc-days-${propertyId}`);
const totalSpan = document.getElementById(`tpm-calc-total-${propertyId}`);
if (rentSpan) rentSpan.textContent = FormatCurrency(currentRent);
if (daysSpan) daysSpan.textContent = days;
if (totalSpan) totalSpan.textContent = FormatCurrency(total);
}
costInput.addEventListener('input', updateCalculator);
daysInput.addEventListener('input', updateCalculator);
updateCalculator();
}
function resolveTenantName(tenantID) {
const store = getStore();
const stored = store.tenants?.[tenantID];
if (stored && stored.n && stored.n !== 'Unknown' && stored.n !== 'Unknown Tenant') {
return stored.n;
}
const properties = safeJSON("prop_cache");
for (const [id, p] of properties) {
if (p.rented_by?.id == tenantID && p.rented_by?.name && p.rented_by.name !== 'Unknown') {
return p.rented_by.name;
}
if (p.renter_asked?.id == tenantID && p.renter_asked?.name && p.renter_asked.name !== 'Unknown') {
return p.renter_asked.name;
}
}
if (store.events) {
for (const propEvents of Object.values(store.events)) {
for (const e of propEvents) {
if (e[5] == tenantID) {
const tenant = store.tenants?.[e[5]];
if (tenant && tenant.n && tenant.n !== 'Unknown' && tenant.n !== 'Unknown Tenant') {
return tenant.n;
}
}
}
}
}
return tenantID;
}
function addLeaseHistory(container, propertyId) {
const existing = container.parentNode
? container.parentNode.querySelectorAll('.tpm-history-section')
: [];
existing.forEach(el => {
if (el._tpmListener) {
window.removeEventListener(STORE_EVENTS.UPDATED, el._tpmListener);
}
el.remove();
});
const store = getStore();
if (!store.events) return;
const propertyEvents = store.events[propertyId] || [];
const tenantNotes = safeJSON("tpm_tenant_notes") || {};
const allEvents = [];
const apiKey = localStorage.getItem('tpmApiKey');
propertyEvents.forEach(e => {
const tenantID = String(e[5] ?? '').trim();
const tenant = store.tenants?.[tenantID];
let displayName, hasNotes, type;
const isSystem =
!tenantID ||
tenantID === '0' ||
tenantID === 'unknown';
if (isSystem) {
displayName = 'šļø Torn System';
hasNotes = false;
type = 'system';
}
else {
let resolvedName = null;
if (!tenant || !tenant.n || tenant.n === 'Unknown' || tenant.n === 'Unknown Tenant') {
resolvedName = resolveTenantName(tenantID);
}
if (resolvedName && resolvedName !== tenantID) {
displayName = resolvedName;
hasNotes = (tenantNotes?.[tenantID]?.length || 0) > 0;
type = 'knownUser';
if (apiKey && !window[`fetching_${tenantID}`]) {
window[`fetching_${tenantID}`] = true;
setTimeout(() => {
fetch(`https://api.torn.com/v2/user/${tenantID}?key=${apiKey}`)
.then(r => r.json())
.then(data => {
if (data && data.name && data.name !== 'Unknown') {
storeTenantInfo(tenantID, data.name);
}
delete window[`fetching_${tenantID}`];
})
.catch(() => delete window[`fetching_${tenantID}`]);
}, 100);
}
}
else if (tenant && tenant.n && tenant.n !== 'Unknown' && tenant.n !== 'Unknown Tenant') {
displayName = tenant.n;
hasNotes = (tenantNotes?.[tenantID]?.length || 0) > 0;
type = 'knownUser';
}
else {
displayName = tenantID;
hasNotes = false;
type = 'unknownUser';
if (apiKey && !window[`fetching_${tenantID}`]) {
window[`fetching_${tenantID}`] = true;
setTimeout(() => {
fetch(`https://api.torn.com/v2/user/${tenantID}?key=${apiKey}`)
.then(r => r.json())
.then(data => {
if (data && data.name && data.name !== 'Unknown') {
storeTenantInfo(tenantID, data.name);
}
delete window[`fetching_${tenantID}`];
})
.catch(() => delete window[`fetching_${tenantID}`]);
}, 100);
}
}
}
allEvents.push({
time: e[0],
event: EVENT_NAMES[e[1]] || 'unknown',
dailyRent: e[2],
days: e[3],
totalPayment: e[4],
tenantID,
displayName,
hasNotes,
type
});
});
const sortedEvents = [...allEvents].sort(
(a, b) => Number(b.time) - Number(a.time)
);
const historyDiv = document.createElement('details');
historyDiv.className = 'tpm-history-section';
historyDiv.dataset.propertyId = propertyId;
let historySummary = sortedEvents.length > 0
? `š Complete Property History: āļø ${sortedEvents.length} Found.`
: 'š Complete Property History: ā 0 Found.';
let historyHtml =
`<summary style="font-size:11px; color:var(--tpm-info); cursor:pointer; margin-bottom:4px;">${historySummary}</summary>`;
historyHtml +=
'<div style="max-height:200px; overflow-y:auto; background:var(--tpm-card-bg); padding:8px; border-radius:4px;">';
if (sortedEvents.length > 0) {
sortedEvents.forEach(e => {
const date = e.time
? new Date(Number(e.time)).toLocaleDateString()
: 'Unknown date';
const eventText = (e.event || '').toLowerCase();
const eventClass =
eventText.includes('rented') ||
eventText.includes('accepted')
? 'tpm-text-success'
: eventText.includes('decline')
? 'tpm-text-danger'
: 'tpm-text-soft';
let displayHtml;
if (e.type === 'system') {
displayHtml = e.displayName;
}
else if (e.type === 'unknownUser') {
displayHtml =
`šļø Torn System: [No Name] [ID Link: <a href="https://www.torn.com/profiles.php?XID=${e.tenantID}" target="${getLinkTarget()}" style="color:var(--tpm-link);">${e.displayName}</a>]`;
}
else {
const noteIcon = e.hasNotes ? 'š' : 'š';
displayHtml =
`<span class="tpm-note-icon" data-tenant="${e.tenantID}" data-name="${e.displayName}" data-active="false">${noteIcon}</span> ` +
`<a href="https://www.torn.com/profiles.php?XID=${e.tenantID}" target="${getLinkTarget()}" style="color:var(--tpm-link);">${e.displayName}</a>`;
}
historyHtml +=
`<div class="tpm-history-item">
<div class="tpm-history-main">
<span>${date} - ${displayHtml}</span>
<span class="${eventClass}">${(e.event || '').replace(/_/g, ' ')}</span>
</div>
<div class="tpm-history-details">
<span>${e.days ? e.days + ' days' : ''}</span>
<span>${e.dailyRent ? '$' + Number(e.dailyRent).toLocaleString() + '/d' : ''}</span>
<span>${e.totalPayment ? 'Total: $' + Number(e.totalPayment).toLocaleString() : ''}</span>
</div>
</div>`;
});
} else {
historyHtml +=
'<div class="tpm-history-empty">No lease records found for this property</div>';
}
historyHtml += '</div>';
historyDiv.innerHTML = historyHtml;
const listener = (e) => {
if (historyDiv.isConnected) {
addLeaseHistory(container, propertyId);
} else {
window.removeEventListener(STORE_EVENTS.UPDATED, listener);
}
};
historyDiv._tpmListener = listener;
window.addEventListener(STORE_EVENTS.UPDATED, listener);
if (container.parentNode) {
const warningDiv = container.querySelector('.tpm-autofill-warning');
if (warningDiv) {
warningDiv.insertAdjacentElement('afterend', historyDiv);
} else {
container.parentNode.appendChild(historyDiv);
}
} else {
container.appendChild(historyDiv);
}
setTimeout(() => {
historyDiv.querySelectorAll('.tpm-note-icon').forEach(icon => {
icon.onclick = (e) => {
e.stopPropagation();
const tenant = icon.dataset.tenant;
const name = icon.dataset.name;
const active = icon.dataset.active === 'true';
showNotesBook(tenant, name, active);
};
});
}, 100);
}
function observeLeaseForms() {
if (!isLeaseAutofillEnabled()) return;
const url = new URL(window.location.href);
if (!url.hash.includes('tab=offerExtension') && !url.hash.includes('tab=lease')) return;
const propertyId = url.hash.match(/[Ii][Dd]=(\d+)/)?.[1];
if (!propertyId) return;
if (formFillTimeout) {
clearTimeout(formFillTimeout);
formFillTimeout = null;
}
const propertyData = getPropertyAutofill(propertyId);
const store = getStore();
const propertyEvents = store.events?.[propertyId] || [];
const sortedEvents = propertyEvents.sort((a,b) => b[0] - a[0]);
const lastEvent = sortedEvents.length > 0 ? sortedEvents[0] : null;
let rentValue = 0;
let leaseValue = 0;
if (lastEvent && lastEvent[2] && lastEvent[3]) {
rentValue = lastEvent[2];
leaseValue = lastEvent[3];
if (isDebugMode()) console.log(`TPM: Using last record - ${FormatCurrency(rentValue)}/d for ${leaseValue} days`);
} else if (propertyData) {
rentValue = propertyData?.rent ?? 0;
leaseValue = propertyData?.lease ?? 0;
if (isDebugMode()) console.log(`TPM: Using autofill - ${FormatCurrency(rentValue)}/d for ${leaseValue} days`);
}
const currentType = propertyData?.type || 'Unknown';
const stored = JSON.parse(localStorage.getItem('tpm_property_autofill') || "{}");
const typeRecords = Object.values(stored).filter(v => v.type === currentType && v.status === "rented" && v.rent > 0 && v.lease > 0);
let hasValidRecords = false;
if (typeRecords.length > 0) {
hasValidRecords = true;
if (rentValue === 0) {
let rents = typeRecords.map(v => v.rent).sort((a, b) => a - b);
const percentile = (arr, p) => {
const index = (arr.length - 1) * p;
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) return arr[index];
return Math.round(arr[lower] + (arr[upper] - arr[lower]) * (index - lower));
};
const q1 = percentile(rents, 0.25);
const q3 = percentile(rents, 0.75);
const iqr = q3 - q1;
const min = q1 - 1.5 * iqr;
const max = q3 + 1.5 * iqr;
const filtered = rents.filter(v => v >= min && v <= max);
if (filtered.length) rents = filtered;
const avgRent = Math.round(rents.reduce((a, b) => a + b, 0) / rents.length);
const medianRent = percentile(rents, 0.50);
rentValue = medianRent;
}
}
if (leaseValue > 100) leaseValue = 100;
if (leaseValue < 7) leaseValue = 7;
let totalCost = rentValue * leaseValue;
const checkAndFillForms = () => {
if (formProcessed) return;
const costInputs = document.querySelectorAll(
'input[data-name="offercost"]:not([data-tpm-filled]),' +
'input[data-name="money"]:not([data-tpm-filled]),' +
'input[name="cost"]:not([data-tpm-filled])'
);
costInputs.forEach(costInput => {
const container = costInput.closest('ul') || costInput.parentElement;
const daysInput = container.querySelector('input[data-name="days"], input[name="days"]');
if (!daysInput) return;
costInput.value = totalCost;
daysInput.value = leaseValue;
const costHidden = container.querySelector(
'input[type="hidden"][name="offercost"], input[type="hidden"][name="money"]'
);
const daysHidden = container.querySelector('input[type="hidden"][name="days"]');
if (costHidden) costHidden.value = totalCost;
if (daysHidden) daysHidden.value = leaseValue;
[costInput, daysInput].forEach(el => {
['input', 'change', 'blur'].forEach(ev => el.dispatchEvent(new Event(ev, { bubbles: true })));
});
costInput.setAttribute('data-tpm-filled', 'true');
const propertyInfoCont = document.querySelector('.property-info-cont');
if (!propertyInfoCont) return;
document.querySelectorAll('.tpm-market-suggestion, .tpm-calculator, .tpm-autofill-warning, .tpm-history-section, .tpm-smart-pricing').forEach(el => el.remove());
if (typeRecords.length) {
const rents = typeRecords.map(v => v.rent).sort((a, b) => a - b);
const percentile = (arr, p) => {
const index = (arr.length - 1) * p;
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) return arr[index];
return Math.round(arr[lower] + (arr[upper] - arr[lower]) * (index - lower));
};
const low = percentile(rents, 0.25);
const median = percentile(rents, 0.50);
const high = percentile(rents, 0.75);
const avg = Math.round(rents.reduce((a, b) => a + b, 0) / rents.length);
const marketDiv = document.createElement('div');
marketDiv.className = 'tpm-market-suggestion';
marketDiv.style.cssText = 'margin-bottom:8px;padding:8px;background:var(--tpm-card-bg);border-radius:4px;font-size:11px;color:var(--tpm-text-soft);';
marketDiv.innerHTML = `š Market Analysis (${typeRecords.length} records) Ā· Avg: ${FormatCurrency(avg)} Ā· Median: ${FormatCurrency(median)} Ā· Range: ${FormatCurrency(low)}-${FormatCurrency(high)}`;
propertyInfoCont.insertAdjacentElement('beforebegin', marketDiv);
}
const calculatorDiv = document.createElement('div');
calculatorDiv.className = 'tpm-calculator';
calculatorDiv.style.cssText = 'margin-bottom:8px;background:var(--tpm-calculator-bg);border:1px solid var(--tpm-calculator-border);border-radius:6px;padding:10px 16px;font-size:13px;';
calculatorDiv.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; width:100%;">
<span><span style="color:var(--tpm-text-soft);">Daily Rent:</span> <span id="tpm-calc-rent-${propertyId}" style="font-weight:600;">${FormatCurrency(rentValue)}</span></span>
<span style="color:var(--tpm-text-soft);">Ć</span>
<span><span style="color:var(--tpm-text-soft);">Days:</span> <span id="tpm-calc-days-${propertyId}" style="font-weight:600;">${leaseValue}</span></span>
<span style="color:var(--tpm-text-soft);">=</span>
<span><span style="color:var(--tpm-text-soft);">Total:</span> <span id="tpm-calc-total-${propertyId}" class="tpm-calculator-value" style="font-weight:600;color:var(--tpm-success);background:rgba(46,125,50,0.1);padding:4px 10px;border-radius:20px;">${FormatCurrency(totalCost)}</span></span>
</div>
`;
const lastBefore = propertyInfoCont.previousElementSibling;
if (lastBefore && lastBefore.classList.contains('tpm-market-suggestion')) {
lastBefore.insertAdjacentElement('afterend', calculatorDiv);
} else {
propertyInfoCont.insertAdjacentElement('beforebegin', calculatorDiv);
}
function updateCalculator() {
// const visibleForm = document.querySelector('.offerExtension-form:not([style*="display: none"]), .lease-form:not([style*="display: none"])');
let visibleForm = document.querySelector('.offerExtension-form:not([style*="display: none"]), .lease-form:not([style*="display: none"])');
if (!visibleForm) {
const allForms = Array.from(document.querySelectorAll('form, div[class*="form"], ul[class*="form"]')).filter(form => {
if (form.offsetParent === null) return false;
const hasCost = form.querySelector('input[data-name="offercost"], input[data-name="money"], input[name="cost"]');
const hasDays = form.querySelector('input[data-name="days"], input[name="days"]');
return hasCost && hasDays;
});
visibleForm = allForms[0];
}
if (!visibleForm) return;
const visibleCost = visibleForm.querySelector('input[data-name="offercost"], input[data-name="money"], input[name="cost"]');
const visibleDays = visibleForm.querySelector('input[data-name="days"], input[name="days"]');
if (!visibleCost || !visibleDays) return;
const days = parseInt(visibleDays.value) || 0;
const currentRent = parseFloat(visibleCost.value.replace(/[^0-9]/g, '')) / (days || 1) || 0;
const total = currentRent * days;
const rentSpan = document.getElementById(`tpm-calc-rent-${propertyId}`);
const daysSpan = document.getElementById(`tpm-calc-days-${propertyId}`);
const totalSpan = document.getElementById(`tpm-calc-total-${propertyId}`);
if (rentSpan) rentSpan.textContent = FormatCurrency(currentRent);
if (daysSpan) daysSpan.textContent = days;
if (totalSpan) totalSpan.textContent = FormatCurrency(total);
}
costInput.addEventListener('input', updateCalculator);
daysInput.addEventListener('input', updateCalculator);
updateCalculator();
calculatorDiv._tpmUpdate = updateCalculator;
const warnDiv = document.createElement('div');
warnDiv.className = 'tpm-autofill-warning';
warnDiv.style.cssText = 'margin-bottom:8px;padding:8px;background:#332600;color:#ffcc00;border-radius:4px;font-size:11px;text-align:center;';
const hasValidRecord = propertyData && propertyData.rent > 0 && propertyData.lease > 0;
warnDiv.textContent = hasValidRecord || hasValidRecords
? `š¢ Records Discovered: ${FormatCurrency(rentValue)} per day for ${leaseValue} days totaling ${FormatCurrency(totalCost)}.`
: "š“ No stored records found for this property.";
propertyInfoCont.insertAdjacentElement('beforebegin', warnDiv);
const propertyInfoContParent = document.querySelector('.property-info-cont');
if (propertyInfoContParent && !window._tpmTabListenerAdded) {
const tabs = propertyInfoContParent.querySelector('.tabs a, .tabs button');
if (tabs) {
window._tpmTabListenerAdded = true;
propertyInfoContParent.addEventListener('click', (e) => {
const tabBtn = e.target.closest('.tabs a, .tabs button');
if (tabBtn) {
setTimeout(() => {
const calculator = document.querySelector('.tpm-calculator');
if (calculator && calculator._tpmUpdate) {
calculator._tpmUpdate();
}
}, 50);
}
});
}
}
const historyContainer = container.closest('div[id="market"], div[id="user"]') || propertyInfoCont;
if (historyContainer && !historyContainer.querySelector('.tpm-history-section')) {
addLeaseHistory(historyContainer, propertyId);
}
setTimeout(() => {
const historyDiv = document.querySelector('.tpm-history-section');
if (historyDiv) {
propertyInfoCont.insertAdjacentElement('afterend', historyDiv);
}
}, 100);
setTimeout(() => {
const historyDiv = document.querySelector('.tpm-history-section');
const calculatorDiv = document.querySelector('.tpm-calculator');
if (historyDiv && calculatorDiv) {
const lastAfter = propertyInfoCont.nextElementSibling;
if (lastAfter && lastAfter.classList.contains('tpm-history-section')) {
lastAfter.insertAdjacentElement('afterend', calculatorDiv);
} else {
propertyInfoCont.insertAdjacentElement('afterend', calculatorDiv);
}
}
}, 200);
formProcessed = true;
});
};
formFillTimeout = setTimeout(checkAndFillForms, 500);
if (window.tpmLeaseObserver) {
window.tpmLeaseObserver.disconnect();
window.tpmLeaseObserver = null;
}
window.tpmLeaseObserver = new MutationObserver(checkAndFillForms);
window.tpmLeaseObserver.observe(document.body, { childList: true, subtree: true });
}
let lastPropertyID = null;
setInterval(() => {
const id = getCurrentPropertyID();
if (id && id !== lastPropertyID) {
lastPropertyID = id;
formProcessed = false;
observeLeaseForms();
}
}, 800);
const pageObserver = new MutationObserver(() => {
const target = document.getElementById('properties-page-wrap');
if (target && !document.getElementById('torn-property-manager-root')) initWhenReady();
});
pageObserver.observe(document.body, { childList: true, subtree: true });
window.addEventListener('beforeunload', () => { tabChannel.close(); });
tabChannel.addEventListener('message', (event) => {
const { type, data, timestamp } = event.data;
if (timestamp <= TAB_COORDINATION.lastUpdate) return;
switch(type) {
case 'PROPERTIES_UPDATED':
localStorage.setItem('prop_cache', JSON.stringify(data.properties));
localStorage.setItem('prop_last_call', data.timestamp);
TAB_COORDINATION.lastUpdate = timestamp;
if (isDebugMode()) console.log('TPM: Properties updated from another tab');
if (document.getElementById('prop-content')?.style.display === 'block') {
loadProperties();
}
break;
case 'LOGS_UPDATED':
localStorage.setItem('tenant_logs_cache', JSON.stringify(data.logs));
localStorage.setItem('tenant_logs_last_call', data.timestamp);
TAB_COORDINATION.lastUpdate = timestamp;
if (isDebugMode()) console.log('TPM: Lease logs updated from another tab');
if (document.getElementById('tenantintel-content')?.style.display === 'block') {
renderTenantIntel();
}
break;
case 'STORE_UPDATED':
localStorage.setItem('tpm_store_v2', JSON.stringify(data.store));
localStorage.setItem('tpm_archive', JSON.stringify(data.archive));
tpmArchive = data.archive;
TAB_COORDINATION.lastUpdate = timestamp;
if (isDebugMode()) console.log('TPM: Store updated from another tab');
if (document.getElementById('tenantintel-content')?.style.display === 'block') {
renderTenantIntel();
}
break;
}
});
function initWhenReady() {
const target = document.getElementById('properties-page-wrap');
if (!target) return;
if (document.getElementById('torn-property-manager-root')) return;
target.insertAdjacentHTML('afterbegin', `
<div id="torn-property-manager-root" style="${STYLES.container}">
<div id="alert-summary" style="margin-bottom:8px;"></div>
<div class="tpm-nav">
<button class="tpm-nav-item" id="PropList-toggle">ā¶ļø Properties</button>
<div class="tpm-nav-divider"></div>
<button class="tpm-nav-item" id="TenantIntel-toggle">ā¶ļø Intel</button>
<button class="tpm-nav-item" id="Settings-toggle">ā¶ļø Settings</button>
<div class="tpm-nav-divider"></div>
<button class="tpm-nav-item" id="help-toggle">ā¶ļø Help</button>
<div class="tpm-nav-divider"></div>
<button class="tpm-nav-item" id="refreshBtn">š Refresh</button>
</div>
<div id="prop-content" style="display:none;"></div>
<div id="tenantintel-content" style="display:none;"></div>
<div id="settings-content" style="display:none;"></div>
<div id="help-content" style="display:none;"></div>
</div>
`);
const propBtn = document.getElementById('PropList-toggle');
const intelBtn = document.getElementById('TenantIntel-toggle');
const settingsBtn = document.getElementById('Settings-toggle');
const helpBtn = document.getElementById('help-toggle');
const refreshBtn = document.getElementById('refreshBtn');
const propContent = document.getElementById('prop-content');
const intelContent = document.getElementById('tenantintel-content');
const settingsContent = document.getElementById('settings-content');
const helpContent = document.getElementById('help-content');
function hideAll() {
propContent.style.display = 'none';
intelContent.style.display = 'none';
settingsContent.style.display = 'none';
helpContent.style.display = 'none';
}
propBtn.onclick = () => {
if (propContent.style.display === 'block') {
hideAll();
propBtn.textContent = 'ā¶ļø Properties';
intelBtn.textContent = 'ā¶ļø Intel';
settingsBtn.textContent = 'ā¶ļø Settings';
helpBtn.textContent = 'ā¶ļø Help';
} else {
hideAll();
propContent.style.display = 'block';
propBtn.textContent = 'š½ Properties';
intelBtn.textContent = 'ā¶ļø Intel';
settingsBtn.textContent = 'ā¶ļø Settings';
helpBtn.textContent = 'ā¶ļø Help';
}
};
intelBtn.onclick = () => {
if (intelContent.style.display === 'block') {
hideAll();
propBtn.textContent = 'ā¶ļø Properties';
intelBtn.textContent = 'ā¶ļø Intel';
settingsBtn.textContent = 'ā¶ļø Settings';
helpBtn.textContent = 'ā¶ļø Help';
} else {
hideAll();
intelContent.style.display = 'block';
renderTenantIntel();
propBtn.textContent = 'ā¶ļø Properties';
intelBtn.textContent = 'š½ Intel';
settingsBtn.textContent = 'ā¶ļø Settings';
helpBtn.textContent = 'ā¶ļø Help';
}
};
settingsBtn.onclick = () => {
if (settingsContent.style.display === 'block') {
hideAll();
propBtn.textContent = 'ā¶ļø Properties';
intelBtn.textContent = 'ā¶ļø Intel';
settingsBtn.textContent = 'ā¶ļø Settings';
helpBtn.textContent = 'ā¶ļø Help';
} else {
hideAll();
settingsContent.style.display = 'block';
propBtn.textContent = 'ā¶ļø Properties';
intelBtn.textContent = 'ā¶ļø Intel';
settingsBtn.textContent = 'š½ Settings';
helpBtn.textContent = 'ā¶ļø Help';
}
};
helpBtn.onclick = () => {
if (helpContent.style.display === 'block') {
hideAll();
propBtn.textContent = 'ā¶ļø Properties';
intelBtn.textContent = 'ā¶ļø Intel';
settingsBtn.textContent = 'ā¶ļø Settings';
helpBtn.textContent = 'ā¶ļø Help';
} else {
hideAll();
helpContent.style.display = 'block';
renderHelp();
propBtn.textContent = 'ā¶ļø Properties';
intelBtn.textContent = 'ā¶ļø Intel';
settingsBtn.textContent = 'ā¶ļø Settings';
helpBtn.textContent = 'š½ Help';
}
};
refreshBtn.onclick = async () => {
const apiKey = localStorage.getItem("tpmApiKey");
if (!apiKey) return showNotification("No API key set", "error");
await getAllProperties(apiKey, true);
loadProperties();
};
renderHelp();
try {
loadProperties();
} catch (e) {
console.error('TPM: Initial property load failed, but continuing initialization:', e);
if (settingsContent) {
settingsContent.style.display = 'block';
settingsBtn.textContent = 'š½ Settings';
renderSettings('ā ļø Could not load properties. Please check your API key.');
}
}
observeLeaseForms();
initCollapsibleListeners();
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const expanded = document.querySelectorAll(
'.tpm-card.tpm-collapsible .tpm-card-body[style="display: block;"], ' +
'details.tpm-history-section[open], ' +
'#prop-content[style="display: block;"], ' +
'#tenantintel-content[style="display: block;"], ' +
'#settings-content[style="display: block;"], ' +
'#help-content[style="display: block;"]'
);
if (expanded.length > 0 && !e.target.matches('input, textarea, [contenteditable="true"]')) {
expanded.forEach(el => {
if (el.tagName === 'DETAILS') {
el.removeAttribute('open');
} else {
el.style.display = 'none';
if (el.id === 'prop-content') document.getElementById('PropList-toggle').textContent = 'ā¶ļø Properties';
if (el.id === 'tenantintel-content') document.getElementById('TenantIntel-toggle').textContent = 'ā¶ļø Intel';
if (el.id === 'settings-content') document.getElementById('Settings-toggle').textContent = 'ā¶ļø Settings';
if (el.id === 'help-content') document.getElementById('help-toggle').textContent = 'ā¶ļø Help';
}
});
e.preventDefault();
e.stopPropagation();
}
}
});
})();