Overlay on the Properties page showing all owned properties with rental tracking, tenant history, and renewal countdowns. Includes a sidebar widget on all pages showing upcoming renewal due dates.
// ==UserScript==
// @name Torn Property Manager
// @namespace torn_property_manager
// @version 1.1.5
// @description Overlay on the Properties page showing all owned properties with rental tracking, tenant history, and renewal countdowns. Includes a sidebar widget on all pages showing upcoming renewal due dates.
// @author TheOddSod (2640064)
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
// ─── Changelog ───────────────────────────────────────────────────────────
// v1.1.5 — Add: click any column header to sort asc/desc. Active sort shown
// with ▲/▼ indicator in orange. Notes and History cols not sortable.
// v1.1.4 — Add: sidebar rows link to property offer extension page.
// v1.0.3 — Fix: complete rewrite of API response parsing to match confirmed v2 structure.
// v1.0.2 — Fix: handle API returning properties as an array.
// v1.0.1 — Fix: replaced GM_xmlHttpRequest with native fetch().
// v1.0.0 — Initial release.
// ─── Sort state ───────────────────────────────────────────────────────────
// Tracks the current sort column and direction for the main table.
// Column keys match fields on the cached property object.
let sortCol = 'days_left'; // default: soonest renewal first
let sortDir = 'asc'; // 'asc' or 'desc'
// ─── Duplicate injection guard ───────────────────────────────────────────
if (window._tpmLoaded) return;
window._tpmLoaded = true;
// ─── Storage key prefix ──────────────────────────────────────────────────
// All GM keys are prefixed `tpm_` to avoid collisions with other scripts.
const PFX = 'tpm_';
// ─── Config defaults ─────────────────────────────────────────────────────
const DEFAULTS = {
api_key: '',
warn_days: 10, // Amber threshold (days until renewal)
crit_days: 3, // Red threshold (days until renewal)
hide_available: false // Hide unrented properties from overlay
};
// ─── Config helpers ───────────────────────────────────────────────────────
function cfgGet(key) {
const raw = GM_getValue(PFX + 'cfg_' + key, null);
// Return stored value if present, otherwise the default
return raw !== null ? raw : DEFAULTS[key];
}
function cfgSet(key, val) {
GM_setValue(PFX + 'cfg_' + key, val);
}
// ─── Cached property data (shared between overlay and sidebar) ────────────
// Schema: { [propertyId]: { id, name, type, rented: bool, tenant_id, tenant_name,
// days_left, total_cost, cost_per_day, notes, history: [...] } }
function getCachedProperties() {
const raw = GM_getValue(PFX + 'cache', null);
return raw ? JSON.parse(raw) : {};
}
function setCachedProperties(data) {
GM_setValue(PFX + 'cache', JSON.stringify(data));
}
// ─── Torn property type ID → human-readable name ─────────────────────────
// These match Torn's internal property_type integers from the v1 API.
const PROPERTY_TYPES = {
1: 'Private Island', 2: 'Ranch', 3: 'Villa',
4: 'Penthouse', 5: 'Detached House', 6: 'Semi-detached House',
7: 'Terraced House', 8: 'Apartment', 9: 'Flat',
10: 'Studio Flat', 11: 'Shack', 12: 'Beach House',
13: 'Chalet'
};
// ─── API fetch using native fetch() ──────────────────────────────────────
// api.torn.com supports CORS so plain fetch works from Torn pages.
async function apiFetch(path) {
const key = cfgGet('api_key');
if (!key) throw new Error('No API key configured.');
const sep = path.includes('?') ? '&' : '?';
const url = `https://api.torn.com${path}${sep}key=${key}&comment=TornPropertyManager`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.error) throw new Error(`API ${data.error.code}: ${data.error.error}`);
return data;
}
// ─── Inject shared styles (only once, on any page) ───────────────────────
GM_addStyle(`
/* ── Keyframes ── */
@keyframes tpm-spin { to { transform: rotate(360deg); } }
@keyframes tpm-pulse {
0%,100% { border-color: #ff2200; box-shadow: 0 0 6px #ff220066; }
50% { border-color: #ff6644; box-shadow: 0 0 14px #ff220099; }
}
/* ── Overlay root ── */
#tpm-overlay {
font-family: Arial, sans-serif;
font-size: 13px;
color: #e0e0e0;
background: #16213e;
border: 1px solid #2a2a4a;
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
}
/* ── Header ── */
#tpm-header {
background: #1a1a2e;
border-bottom: 2px solid #e05a00;
border-radius: 6px 6px 0 0;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 10px;
}
#tpm-header .tpm-title {
color: #ff7700;
font-size: 15px;
font-weight: bold;
flex: 1;
}
#tpm-header .tpm-version {
font-size: 10px;
font-weight: normal;
opacity: 0.5;
}
#tpm-header .tpm-updated {
font-size: 11px;
color: #888;
}
/* ── Config strip ── */
#tpm-config-strip {
background: #16213e;
border-bottom: 1px solid #1a2a4a;
padding: 5px 12px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
#tpm-config-strip .tpm-strip-label {
font-size: 10px;
color: #888;
text-transform: uppercase;
letter-spacing: .5px;
}
/* ── Config panel ── */
#tpm-config-panel {
background: #111827;
border-bottom: 2px solid #e05a00;
padding: 10px 12px;
display: none;
}
#tpm-config-panel .tpm-cfg-section {
font-size: 9px;
text-transform: uppercase;
letter-spacing: .5px;
color: #555;
margin: 8px 0 4px;
}
#tpm-config-panel .tpm-cfg-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
#tpm-config-panel label {
font-size: 11px;
color: #aaa;
min-width: 140px;
}
#tpm-config-panel input[type=text],
#tpm-config-panel input[type=number] {
background: #0f3460;
border: 1px solid #2a4a7a;
border-radius: 4px;
color: #fff;
padding: 4px 8px;
font-size: 12px;
width: 80px;
}
#tpm-config-panel input[type=text] { width: 220px; }
#tpm-config-panel input:focus { border-color: #ff7700; outline: none; }
#tpm-config-panel input[type=checkbox] { accent-color: #ff7700; }
#tpm-config-panel .tpm-cfg-hint {
font-size: 10px;
color: #555;
}
/* ── Alert banner (renewal due soon) ── */
#tpm-alert-banner {
margin: 10px;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
display: none;
}
#tpm-alert-banner.tpm-crit {
background: #2a0000;
border: 2px solid #ff2200;
color: #ff8866;
display: block;
animation: tpm-pulse 2s ease-in-out infinite;
}
#tpm-alert-banner.tpm-warn {
background: #2a1a00;
border: 1px solid #885500;
color: #ffcc66;
display: block;
}
/* ── Filter bar ── */
#tpm-filter-bar {
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
border-bottom: 1px solid #1a2a4a;
}
#tpm-filter-bar .tpm-filter-label {
font-size: 10px;
color: #888;
text-transform: uppercase;
letter-spacing: .5px;
}
#tpm-filter-bar select {
background: #0f3460;
border: 1px solid #2a4a7a;
border-radius: 4px;
color: #fff;
padding: 3px 6px;
font-size: 11px;
}
#tpm-filter-bar select:focus { border-color: #ff7700; outline: none; }
/* ── Body ── */
#tpm-body { padding: 10px; }
/* ── Property table ── */
#tpm-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
#tpm-table th {
color: #666;
font-weight: normal;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .5px;
padding: 3px 8px;
border-bottom: 1px solid #222;
text-align: left;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
#tpm-table th:hover { color: #aaa; }
#tpm-table th.tpm-sort-asc::after { content: ' ▲'; color: #ff7700; }
#tpm-table th.tpm-sort-desc::after { content: ' ▼'; color: #ff7700; }
/* History column not sortable — no pointer */
#tpm-table th.tpm-no-sort { cursor: default; }
#tpm-table th.tpm-no-sort:hover { color: #666; }
#tpm-table td {
padding: 4px 8px;
border-bottom: 1px solid #111;
color: #ccc;
vertical-align: middle;
}
#tpm-table tr:hover td { background: #1e1e36; }
/* ── Inline edit fields in table ── */
#tpm-table input.tpm-inline {
background: #0f3460;
border: 1px solid #2a4a7a;
border-radius: 3px;
color: #fff;
padding: 2px 6px;
font-size: 11px;
width: 100%;
box-sizing: border-box;
}
#tpm-table input.tpm-inline:focus { border-color: #ff7700; outline: none; }
#tpm-table textarea.tpm-inline {
background: #0f3460;
border: 1px solid #2a4a7a;
border-radius: 3px;
color: #fff;
padding: 2px 6px;
font-size: 11px;
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 30px;
font-family: Arial, sans-serif;
}
#tpm-table textarea.tpm-inline:focus { border-color: #ff7700; outline: none; }
/* ── History expand row ── */
.tpm-history-row td {
background: #111827 !important;
padding: 6px 12px !important;
}
.tpm-history-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
.tpm-history-table th {
color: #555;
font-size: 9px;
text-transform: uppercase;
letter-spacing: .5px;
padding: 2px 6px;
border-bottom: 1px solid #222;
text-align: left;
}
.tpm-history-table td {
padding: 2px 6px;
border-bottom: 1px solid #0a0a0a;
color: #888;
}
/* ── Badges ── */
.tpm-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
font-weight: bold;
white-space: nowrap;
display: inline-block;
}
.tpm-badge-rented { background: #004422; color: #44ee88; }
.tpm-badge-available { background: #1a1a00; color: #888; }
.tpm-badge-warn { background: #2a1a00; color: #ffaa00; }
.tpm-badge-crit { background: #2a0000; color: #ff4444; }
/* ── Countdown colours ── */
.tpm-days-ok { color: #44ee88; font-weight: bold; }
.tpm-days-warn { color: #ffaa00; font-weight: bold; }
.tpm-days-crit { color: #ff4444; font-weight: bold; }
/* ── Buttons ── */
.tpm-btn-primary {
background: #e05a00;
border: none;
border-radius: 4px;
color: #fff;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
font-family: Arial, sans-serif;
}
.tpm-btn-primary:hover { background: #ff7700; }
.tpm-btn-secondary {
background: #1a2a4a;
border: 1px solid #2a4a7a;
border-radius: 4px;
color: #aaa;
padding: 3px 8px;
cursor: pointer;
font-size: 11px;
font-family: Arial, sans-serif;
}
.tpm-btn-secondary:hover { background: #2a3a5a; color: #fff; }
.tpm-btn-small {
background: #1a2a4a;
border: 1px solid #2a4a7a;
border-radius: 3px;
color: #aaa;
padding: 2px 6px;
cursor: pointer;
font-size: 10px;
font-family: Arial, sans-serif;
}
.tpm-btn-small:hover { background: #2a3a5a; color: #fff; }
.tpm-btn-danger {
background: #3a0000;
border: 1px solid #882200;
border-radius: 3px;
color: #ff8866;
padding: 2px 6px;
cursor: pointer;
font-size: 10px;
font-family: Arial, sans-serif;
}
.tpm-btn-danger:hover { background: #550000; }
/* ── Spinner ── */
.tpm-spinner {
display: inline-block;
width: 12px; height: 12px;
border: 2px solid #444;
border-top-color: #ff7700;
border-radius: 50%;
animation: tpm-spin .7s linear infinite;
vertical-align: middle;
}
/* ── Footer ── */
#tpm-footer {
padding: 6px 12px;
font-size: 10px;
color: #444;
border-top: 1px solid #1a2a4a;
text-align: right;
}
/* ── Sidebar widget ── */
#tpm-sidebar-widget {
font-family: Arial, sans-serif;
font-size: 12px;
color: #e0e0e0;
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 6px;
overflow: hidden;
margin-bottom: 10px;
}
#tpm-sidebar-widget .tpm-sw-header {
background: #1a1a2e;
border-bottom: 2px solid #e05a00;
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
#tpm-sidebar-widget .tpm-sw-title {
color: #ff7700;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: .5px;
}
#tpm-sidebar-widget .tpm-sw-toggle {
color: #555;
font-size: 10px;
transition: transform .2s;
}
#tpm-sidebar-widget .tpm-sw-toggle.collapsed { transform: rotate(-90deg); }
#tpm-sidebar-widget .tpm-sw-body { padding: 6px 0; }
#tpm-sidebar-widget .tpm-sw-row {
padding: 4px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
border-bottom: 1px solid #111;
}
#tpm-sidebar-widget .tpm-sw-row:last-child { border-bottom: none; }
#tpm-sidebar-widget a:hover .tpm-sw-row { background: #1e1e36; }
#tpm-sidebar-widget .tpm-sw-name {
font-size: 11px;
color: #ccc;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#tpm-sidebar-widget .tpm-sw-tenant {
font-size: 10px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#tpm-sidebar-widget .tpm-sw-days {
font-size: 11px;
font-weight: bold;
white-space: nowrap;
}
#tpm-sidebar-widget .tpm-sw-empty {
padding: 6px 10px;
font-size: 11px;
color: #555;
}
/* Pulsing alert on the sidebar widget header when something is critical */
#tpm-sidebar-widget.tpm-sw-alert .tpm-sw-header {
animation: tpm-pulse 2s ease-in-out infinite;
border-bottom-color: #ff2200;
}
`);
// ─────────────────────────────────────────────────────────────────────────
// SIDEBAR WIDGET — injected on ALL pages, reads cached data only
// ─────────────────────────────────────────────────────────────────────────
function injectSidebarWidget() {
// Retry until Torn's right sidebar is present.
// Torn uses #sidebar for the right-hand column on most pages.
// We try selectors in order of specificity, skipping the top nav/header areas.
const tryInsert = setInterval(() => {
const sidebar =
document.querySelector('#sidebar') ||
document.querySelector('.sidebar-block-wrap')||
document.querySelector('#sidebarroot') ||
document.querySelector('[class*="sidebar-blocks"]');
if (!sidebar) return;
clearInterval(tryInsert);
// Don't double-inject
if (document.getElementById('tpm-sidebar-widget')) return;
const widget = document.createElement('div');
widget.id = 'tpm-sidebar-widget';
// Insert as the first child of the sidebar
sidebar.insertBefore(widget, sidebar.firstChild);
renderSidebarWidget();
}, 500);
}
function renderSidebarWidget() {
const widget = document.getElementById('tpm-sidebar-widget');
if (!widget) return;
const props = getCachedProperties();
const warnDays = parseInt(cfgGet('warn_days'), 10);
const critDays = parseInt(cfgGet('crit_days'), 10);
// Only show rented properties in the sidebar
const rented = Object.values(props).filter(p => p.rented && p.days_left != null);
// Determine overall alert state for widget header pulse
const hasCrit = rented.some(p => p.days_left <= critDays);
const hasWarn = rented.some(p => p.days_left <= warnDays);
widget.classList.toggle('tpm-sw-alert', hasCrit);
// Restore collapsed state
const isCollapsed = GM_getValue(PFX + 'sw_collapsed', '0') === '1';
widget.innerHTML = `
<div class="tpm-sw-header" id="tpm-sw-hdr">
<span class="tpm-sw-title">🏠 Renewals</span>
<span class="tpm-sw-toggle ${isCollapsed ? 'collapsed' : ''}">▼</span>
</div>
<div class="tpm-sw-body" id="tpm-sw-body" style="display:${isCollapsed ? 'none' : ''}">
${rented.length === 0
? '<div class="tpm-sw-empty">No active rentals</div>'
: rented
.sort((a, b) => a.days_left - b.days_left)
.map(p => {
let cls = 'tpm-days-ok';
if (p.days_left <= critDays) cls = 'tpm-days-crit';
else if (p.days_left <= warnDays) cls = 'tpm-days-warn';
const label = p.days_left === 0 ? 'Due today' : `${p.days_left}d`;
return `
<a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=offerExtension"
target="_blank" style="text-decoration:none;color:inherit;">
<div class="tpm-sw-row">
<div style="flex:1;overflow:hidden;">
<div class="tpm-sw-name" title="${escHtml(p.name)}">${escHtml(p.name)}</div>
<div class="tpm-sw-tenant" title="${escHtml(p.tenant_name || '')}">${escHtml(p.tenant_name || '—')}</div>
</div>
<span class="tpm-sw-days ${cls}">${label}</span>
</div>
</a>`;
}).join('')
}
</div>
`;
// Toggle collapse
document.getElementById('tpm-sw-hdr').addEventListener('click', () => {
const body = document.getElementById('tpm-sw-body');
const arrow = widget.querySelector('.tpm-sw-toggle');
const nowHide = body.style.display !== 'none';
body.style.display = nowHide ? 'none' : '';
arrow.classList.toggle('collapsed', nowHide);
GM_setValue(PFX + 'sw_collapsed', nowHide ? '1' : '0');
});
}
// ─────────────────────────────────────────────────────────────────────────
// PROPERTIES PAGE OVERLAY
// ─────────────────────────────────────────────────────────────────────────
function isPropertiesPage() {
return location.href.includes('properties.php');
}
function injectOverlay() {
// Retry until the Torn properties content block is available
const tryInsert = setInterval(() => {
const anchor = document.querySelector('.properties-wrap, #properties-page, .content-title');
if (!anchor) return;
clearInterval(tryInsert);
if (document.getElementById('tpm-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'tpm-overlay';
// Insert before the existing content
anchor.parentNode.insertBefore(overlay, anchor);
renderOverlay(overlay);
}, 500);
}
function renderOverlay(container) {
const warnDays = parseInt(cfgGet('warn_days'), 10);
const critDays = parseInt(cfgGet('crit_days'), 10);
const hideAvail = cfgGet('hide_available') === true || cfgGet('hide_available') === 'true';
const apiKey = cfgGet('api_key');
const props = getCachedProperties();
const lastUpdated = GM_getValue(PFX + 'last_updated', null);
// Build the full HTML shell
container.innerHTML = `
<!-- ── Header ── -->
<div id="tpm-header">
<span class="tpm-title">🏠 Property Manager <span class="tpm-version">v1.1.5</span></span>
<span class="tpm-updated" id="tpm-last-updated">
${lastUpdated ? 'Updated ' + timeAgo(lastUpdated) : 'Not yet loaded'}
</span>
<button class="tpm-btn-secondary" id="tpm-refresh-btn">↻ Refresh</button>
<button class="tpm-btn-secondary" id="tpm-config-btn">⚙ Config</button>
</div>
<!-- ── Config panel (hidden by default) ── -->
<div id="tpm-config-panel">
<div class="tpm-cfg-section">API Key</div>
<div class="tpm-cfg-row">
<label>Torn API Key</label>
<input type="password" id="tpm-cfg-apikey" placeholder="16-character key" value="${escHtml(apiKey)}" />
<span class="tpm-cfg-hint">Requires: User → Properties (Limited Access)</span>
</div>
<div class="tpm-cfg-section">Renewal Alert Thresholds</div>
<div class="tpm-cfg-row">
<label>⚠ Amber warning (days)</label>
<input type="number" id="tpm-cfg-warn" min="1" max="99" value="${warnDays}" />
<span class="tpm-cfg-hint">Amber badge / sidebar highlight</span>
</div>
<div class="tpm-cfg-row">
<label>🔴 Red alert (days)</label>
<input type="number" id="tpm-cfg-crit" min="1" max="99" value="${critDays}" />
<span class="tpm-cfg-hint">Red badge + pulsing banner</span>
</div>
<div class="tpm-cfg-section">Display Options</div>
<div class="tpm-cfg-row">
<label>Hide available properties</label>
<input type="checkbox" id="tpm-cfg-hide" ${hideAvail ? 'checked' : ''} />
<span class="tpm-cfg-hint">Only show currently rented properties</span>
</div>
<div class="tpm-cfg-row">
<label>Show spouse-owned properties</label>
<input type="checkbox" id="tpm-cfg-spouse" ${cfgGet('show_spouse') === 'true' || cfgGet('show_spouse') === true ? 'checked' : ''} />
<span class="tpm-cfg-hint">Include properties owned by your spouse</span>
</div>
<div class="tpm-cfg-row" style="margin-top:8px;">
<button class="tpm-btn-primary" id="tpm-cfg-save">Save Config</button>
</div>
</div>
<!-- ── Alert banner ── -->
<div id="tpm-alert-banner"></div>
<!-- ── Filter bar ── -->
<div id="tpm-filter-bar">
<span class="tpm-filter-label">Filter:</span>
<select id="tpm-filter-type">
<option value="">All Types</option>
${(() => {
// Build unique type options from cached data, falling back to static list
const cached = getCachedProperties();
const seen = {};
Object.values(cached).forEach(p => {
if (p.type && p.name) seen[p.type] = p.name;
});
// Supplement with static list for any types not yet in cache
Object.entries(PROPERTY_TYPES).forEach(([id, name]) => {
if (!seen[id]) seen[id] = name;
});
return Object.entries(seen)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => `<option value="${id}">${name}</option>`)
.join('');
})()}
</select>
<select id="tpm-filter-status">
<option value="hide_in_use">Hide In Use</option>
<option value="">All Statuses</option>
<option value="rented">Rented</option>
<option value="available">Available</option>
<option value="in_use">In Use</option>
</select>
<span id="tpm-loading" style="display:none;"><span class="tpm-spinner"></span> Loading…</span>
<span id="tpm-error-msg" style="color:#ff4444;font-size:11px;display:none;"></span>
</div>
<!-- ── Main body ── -->
<div id="tpm-body">
<table id="tpm-table">
<thead>
<tr id="tpm-thead-row">
<th data-sort="name">Property</th>
<th data-sort="status">Status</th>
<th data-sort="tenant_name">Tenant</th>
<th data-sort="days_left">Days Left</th>
<th data-sort="happy">Happy</th>
<th data-sort="agreed_rent">Agreed Rent ($)</th>
<th class="tpm-no-sort">Notes</th>
<th class="tpm-no-sort">History</th>
</tr>
</thead>
<tbody id="tpm-tbody">
<tr><td colspan="8" style="color:#555;padding:12px 8px;">
${apiKey
? 'Click ↻ Refresh to load your properties.'
: '⚙ Enter your API key in Config to get started.'}
</td></tr>
</tbody>
</table>
</div>
<!-- ── Footer ── -->
<div id="tpm-footer">Torn Property Manager by TheOddSod [2640064]</div>
`;
// Wire up buttons
document.getElementById('tpm-config-btn').addEventListener('click', toggleConfigPanel);
document.getElementById('tpm-cfg-save').addEventListener('click', saveConfig);
document.getElementById('tpm-refresh-btn').addEventListener('click', () => loadProperties(container));
document.getElementById('tpm-filter-type').addEventListener('change', () => rebuildTable(container));
document.getElementById('tpm-filter-status').addEventListener('change', () => rebuildTable(container));
// Wire up sortable column headers
document.querySelectorAll('#tpm-thead-row th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.sort;
if (sortCol === col) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortCol = col;
sortDir = 'asc';
}
rebuildTable(container);
});
});
// If we already have cached data, render it immediately
if (Object.keys(props).length > 0) {
rebuildTable(container);
}
// Auto-load if API key is present
if (apiKey) {
loadProperties(container);
}
}
// ── Toggle config panel ───────────────────────────────────────────────────
function toggleConfigPanel() {
const panel = document.getElementById('tpm-config-panel');
panel.style.display = panel.style.display === 'none' || !panel.style.display
? 'block' : 'none';
}
// ── Save config from panel inputs ─────────────────────────────────────────
function saveConfig() {
cfgSet('api_key', document.getElementById('tpm-cfg-apikey').value.trim());
cfgSet('warn_days', parseInt(document.getElementById('tpm-cfg-warn').value, 10) || 10);
cfgSet('crit_days', parseInt(document.getElementById('tpm-cfg-crit').value, 10) || 3);
cfgSet('hide_available', document.getElementById('tpm-cfg-hide').checked);
cfgSet('show_spouse', document.getElementById('tpm-cfg-spouse').checked);
// Close panel and reload
document.getElementById('tpm-config-panel').style.display = 'none';
const overlay = document.getElementById('tpm-overlay');
if (overlay) loadProperties(overlay);
}
// ── Load properties from API ──────────────────────────────────────────────
async function loadProperties(container) {
const loading = document.getElementById('tpm-loading');
const errorMsg = document.getElementById('tpm-error-msg');
if (loading) loading.style.display = '';
if (errorMsg) errorMsg.style.display = 'none';
try {
// Fetch all owned properties via v2 API.
// Confirmed v2 response shape (from live data):
// { properties: [ { id, owner, property: {id, name}, happy, upkeep,
// status: "rented"|"in_use"|"available"|...,
// rented_by: {id, name} | null,
// rental_period_remaining: <int days> | null,
// cost: <total rent paid> | null,
// cost_per_day: <int> | null,
// rental_period: <total days agreed> | null, ... } ] }
const data = await apiFetch('/v2/user?selections=properties');
// Properties come back as an array keyed by prop.id
const rawArr = Array.isArray(data.properties) ? data.properties : [];
const apiProps = {};
rawArr.forEach(prop => { apiProps[String(prop.id)] = prop; });
// Auto-detect and cache your own Torn user ID from the owner field.
// This lets us filter out spouse-owned properties without hardcoding an ID.
const firstOwned = rawArr.find(p => p.owner && p.owner.id);
if (firstOwned) {
GM_setValue(PFX + 'my_user_id', String(firstOwned.owner.id));
}
// Load existing cached data to preserve manual fields (notes, history, agreed_rent)
const cached = getCachedProperties();
// Merge API data with cached manual data
const updated = {};
for (const [id, prop] of Object.entries(apiProps)) {
const old = cached[id] || {};
// v2 uses status:"rented" + rented_by:{id,name} instead of a rented sub-object.
// rental_period_remaining is days left on the current lease.
const isRented = prop.status === 'rented';
const tenantId = isRented && prop.rented_by ? String(prop.rented_by.id) : null;
const tenantName = isRented && prop.rented_by ? prop.rented_by.name : null;
const daysLeft = isRented ? (parseInt(prop.rental_period_remaining, 10) || 0) : null;
// Property type name comes from the nested property object: prop.property.name
const propTypeName = (prop.property && prop.property.name) || `Property #${id}`;
const propTypeId = (prop.property && prop.property.id) || null;
// ── History tracking ──────────────────────────────────────────
// We record a history entry in three situations:
// 1. Tenant changed — old tenant archived as ended today
// 2. Tenant departed — was rented, now not; archive outgoing tenant
// 3. Same tenant renewed — rental_period_remaining went UP, log a renewal event
let history = old.history || [];
const sameTenant = old.rented && isRented && old.tenant_id === tenantId;
const oldDays = old.days_left != null ? parseInt(old.days_left, 10) : null;
if (old.rented && old.tenant_id) {
if (!isRented) {
// Case 2: tenant has left, property no longer rented
history = [{
type: 'ended',
tenant_id: old.tenant_id,
tenant_name: old.tenant_name || `[${old.tenant_id}]`,
agreed_rent: old.agreed_rent || '',
ended: Math.floor(Date.now() / 1000)
}, ...history].slice(0, 20);
} else if (!sameTenant) {
// Case 1: different tenant now in the property
history = [{
type: 'ended',
tenant_id: old.tenant_id,
tenant_name: old.tenant_name || `[${old.tenant_id}]`,
agreed_rent: old.agreed_rent || '',
ended: Math.floor(Date.now() / 1000)
}, ...history].slice(0, 20);
} else if (sameTenant && oldDays !== null && daysLeft > oldDays) {
// Case 3: same tenant, days went up — they renewed
history = [{
type: 'renewal',
tenant_id: tenantId,
tenant_name: tenantName || `[${tenantId}]`,
agreed_rent: old.agreed_rent || '',
renewed: Math.floor(Date.now() / 1000),
new_days: daysLeft
}, ...history].slice(0, 20);
}
}
updated[id] = {
id,
owner_id: prop.owner ? String(prop.owner.id) : null,
name: propTypeName,
type: propTypeId,
happy: prop.happy,
upkeep: prop.upkeep,
status: prop.status,
rented: isRented,
tenant_id: tenantId,
tenant_name: tenantName,
days_left: daysLeft,
rental_period: prop.rental_period || null,
cost: prop.cost || null,
cost_per_day: prop.cost_per_day || null,
// Preserve manual fields from cache
agreed_rent: old.agreed_rent || '',
notes: old.notes || '',
history
};
}
setCachedProperties(updated);
GM_setValue(PFX + 'last_updated', Date.now());
// Update last-updated label
const lu = document.getElementById('tpm-last-updated');
if (lu) lu.textContent = 'Updated just now';
rebuildTable(container);
// Re-render sidebar widget with fresh data
renderSidebarWidget();
} catch (err) {
if (errorMsg) {
errorMsg.textContent = '⚠ ' + err.message;
errorMsg.style.display = '';
}
} finally {
if (loading) loading.style.display = 'none';
}
}
// ── Rebuild the property table from cached data ───────────────────────────
function rebuildTable(container) {
const props = getCachedProperties();
const warnDays = parseInt(cfgGet('warn_days'), 10);
const critDays = parseInt(cfgGet('crit_days'), 10);
const hideAvail = cfgGet('hide_available') === true || cfgGet('hide_available') === 'true';
const showSpouse = cfgGet('show_spouse') === true || cfgGet('show_spouse') === 'true';
const myUserId = GM_getValue(PFX + 'my_user_id', null);
const filterType = (document.getElementById('tpm-filter-type') || {}).value || '';
const filterStat = (document.getElementById('tpm-filter-status') || {}).value || 'hide_in_use';
const tbody = document.getElementById('tpm-tbody');
if (!tbody) return;
let rows = Object.values(props);
// Filter out spouse-owned properties unless the toggle is on
if (!showSpouse && myUserId) {
rows = rows.filter(p => !p.owner_id || p.owner_id === myUserId);
}
// Apply hide-available config
if (hideAvail) rows = rows.filter(p => p.rented);
// Apply type filter
if (filterType) rows = rows.filter(p => String(p.type) === filterType);
// Apply status filter
// Default selection "hide_in_use" hides properties where the owner/spouse lives
if (filterStat === 'hide_in_use') rows = rows.filter(p => p.status !== 'in_use');
if (filterStat === 'rented') rows = rows.filter(p => p.status === 'rented');
if (filterStat === 'available') rows = rows.filter(p => p.status === 'available');
if (filterStat === 'in_use') rows = rows.filter(p => p.status === 'in_use');
// filterStat === '' shows all statuses with no filtering
// Dynamic sort based on sortCol / sortDir
rows.sort((a, b) => {
let av = a[sortCol], bv = b[sortCol];
// Treat nulls as worst value so they sink to the bottom regardless of direction
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
// Numeric comparison for number fields
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
// String comparison for everything else
av = String(av).toLowerCase();
bv = String(bv).toLowerCase();
const cmp = av.localeCompare(bv);
return sortDir === 'asc' ? cmp : -cmp;
});
// Update header sort indicator classes
document.querySelectorAll('#tpm-thead-row th[data-sort]').forEach(th => {
th.classList.remove('tpm-sort-asc', 'tpm-sort-desc');
if (th.dataset.sort === sortCol) {
th.classList.add(sortDir === 'asc' ? 'tpm-sort-asc' : 'tpm-sort-desc');
}
});
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="color:#555;padding:12px 8px;">No properties match the current filters.</td></tr>`;
updateAlertBanner(props, warnDays, critDays);
return;
}
tbody.innerHTML = rows.map(p => buildPropertyRow(p, warnDays, critDays)).join('');
// Wire up save-on-blur for inline edit fields
tbody.querySelectorAll('.tpm-rent-input').forEach(input => {
input.addEventListener('change', () => saveInlineField(input.dataset.id, 'agreed_rent', input.value));
});
tbody.querySelectorAll('.tpm-notes-input').forEach(input => {
input.addEventListener('change', () => saveInlineField(input.dataset.id, 'notes', input.value));
});
// Wire up history toggles
tbody.querySelectorAll('.tpm-history-btn').forEach(btn => {
btn.addEventListener('click', () => toggleHistory(btn.dataset.id));
});
updateAlertBanner(props, warnDays, critDays);
}
// ── Build a single table row ───────────────────────────────────────────────
function buildPropertyRow(p, warnDays, critDays) {
// Status badge
let statusBadge;
if (p.status === 'in_use') {
statusBadge = `<span class="tpm-badge" style="background:#0f3460;color:#7aadff;">In Use</span>`;
} else if (!p.rented) {
statusBadge = `<span class="tpm-badge tpm-badge-available">Available</span>`;
} else if (p.days_left <= critDays) {
statusBadge = `<span class="tpm-badge tpm-badge-crit">Due Soon</span>`;
} else if (p.days_left <= warnDays) {
statusBadge = `<span class="tpm-badge tpm-badge-warn">Renewing</span>`;
} else {
statusBadge = `<span class="tpm-badge tpm-badge-rented">Rented</span>`;
}
// Days-left cell
let daysCell = '<span style="color:#555">—</span>';
if (p.rented && p.days_left != null) {
let cls = 'tpm-days-ok';
if (p.days_left <= critDays) cls = 'tpm-days-crit';
else if (p.days_left <= warnDays) cls = 'tpm-days-warn';
daysCell = p.days_left === 0
? `<span class="${cls}">Due today</span>`
: `<span class="${cls}">${p.days_left}d</span>`;
}
// Tenant cell — link to profile if we have an ID
let tenantCell = '<span style="color:#555">—</span>';
if (p.rented && p.tenant_id) {
tenantCell = `<a href="https://www.torn.com/profiles.php?XID=${p.tenant_id}"
style="color:#aaccff;text-decoration:none;"
target="_blank">${escHtml(p.tenant_name || `[${p.tenant_id}]`)}</a>`;
}
// Happy cell — colour-coded: green good, amber mid, red low
const happy = p.happy || 0;
const happyCls = happy >= 3000 ? 'color:#44ee88' : happy >= 1000 ? 'color:#ffaa00' : 'color:#ff4444';
const happyCell = `<span style="font-weight:bold;${happyCls}">${happy.toLocaleString()}</span>`;
// History button
const histCount = (p.history || []).length;
const histBtnTxt = histCount > 0 ? `📋 ${histCount}` : '📋';
// Property name cell — action link sits inline after the name.
// Extend link shown when rented; Lease link shown on any non-rented, non-in_use property.
let actionLink = '';
if (p.rented) {
actionLink = ` <a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=offerExtension"
style="color:#ff7700;text-decoration:none;font-size:10px;font-weight:normal;white-space:nowrap;"
target="_blank" title="Offer lease extension">↗ Extend</a>`;
} else if (p.status !== 'in_use') {
actionLink = ` <a href="https://www.torn.com/properties.php#/p=options&ID=${p.id}&tab=lease"
style="color:#44ee88;text-decoration:none;font-size:10px;font-weight:normal;white-space:nowrap;"
target="_blank" title="Lease this property">↗ Lease</a>`;
}
return `
<tr id="tpm-row-${p.id}">
<td><div style="display:flex;align-items:center;gap:6px;flex-wrap:nowrap;">
<span style="color:#fff;font-weight:bold;white-space:nowrap;">${escHtml(p.name)}</span>${actionLink}
</div></td>
<td>${statusBadge}</td>
<td>${tenantCell}</td>
<td>${daysCell}</td>
<td>${happyCell}</td>
<td>
<input class="tpm-inline tpm-rent-input"
data-id="${p.id}"
type="text"
placeholder="e.g. 500000"
value="${escHtml(p.agreed_rent || '')}"
title="Enter the rent amount agreed with your tenant" />
</td>
<td>
<textarea class="tpm-inline tpm-notes-input"
data-id="${p.id}"
placeholder="Notes…"
title="Free-text notes for this property">${escHtml(p.notes || '')}</textarea>
</td>
<td>
<button class="tpm-btn-small tpm-history-btn" data-id="${p.id}" title="View tenant history">
${histBtnTxt}
</button>
</td>
</tr>
<tr class="tpm-history-row" id="tpm-hist-${p.id}" style="display:none;">
<td colspan="8">${buildHistoryTable(p)}</td>
</tr>
`;
}
// ── Build history sub-table for a property ────────────────────────────────
function buildHistoryTable(p) {
const hist = p.history || [];
if (hist.length === 0) {
return `<span style="color:#555;font-size:11px;">No tenant history recorded yet.</span>`;
}
return `
<table class="tpm-history-table">
<thead>
<tr>
<th>Event</th>
<th>Tenant</th>
<th>Agreed Rent</th>
<th>Date</th>
</tr>
</thead>
<tbody>
${hist.map(h => {
const isRenewal = h.type === 'renewal';
const typeBadge = isRenewal
? `<span class="tpm-badge" style="background:#004422;color:#44ee88;">Renewed</span>`
: `<span class="tpm-badge" style="background:#2a0000;color:#ff8866;">Ended</span>`;
const dateTs = isRenewal ? h.renewed : h.ended;
const dateStr = dateTs ? new Date(dateTs * 1000).toLocaleDateString() : '—';
const extra = isRenewal && h.new_days
? ` <span style="color:#555;font-size:9px;">(+${h.new_days}d)</span>` : '';
return `
<tr>
<td>${typeBadge}</td>
<td>
<a href="https://www.torn.com/profiles.php?XID=${h.tenant_id}"
style="color:#aaccff;text-decoration:none;" target="_blank">
${escHtml(h.tenant_name || `[${h.tenant_id}]`)}
</a>
</td>
<td>${h.agreed_rent ? '$' + escHtml(String(h.agreed_rent)) : '—'}</td>
<td>${dateStr}${extra}</td>
</tr>`;
}).join('')}
</tbody>
</table>
`;
}
// ── Toggle history sub-row ─────────────────────────────────────────────────
function toggleHistory(id) {
const row = document.getElementById(`tpm-hist-${id}`);
if (!row) return;
row.style.display = row.style.display === 'none' ? '' : 'none';
}
// ── Save a single inline field to cache ───────────────────────────────────
function saveInlineField(id, field, value) {
const props = getCachedProperties();
if (!props[id]) return;
props[id][field] = value;
setCachedProperties(props);
}
// ── Update the alert banner above the table ───────────────────────────────
function updateAlertBanner(props, warnDays, critDays) {
const banner = document.getElementById('tpm-alert-banner');
if (!banner) return;
const rented = Object.values(props).filter(p => p.rented && p.days_left != null);
const critProps = rented.filter(p => p.days_left <= critDays);
const warnProps = rented.filter(p => p.days_left <= warnDays && p.days_left > critDays);
banner.className = 'tpm-alert-banner'; // Reset
if (critProps.length > 0) {
banner.className += ' tpm-crit';
const names = critProps.map(p => `${p.name} (${p.days_left === 0 ? 'due today' : p.days_left + 'd'})`).join(', ');
banner.textContent = `🔴 Renewal due very soon: ${names}`;
} else if (warnProps.length > 0) {
banner.className += ' tpm-warn';
const names = warnProps.map(p => `${p.name} (${p.days_left}d)`).join(', ');
banner.textContent = `⚠ Renewal approaching: ${names}`;
} else {
banner.style.display = 'none';
return;
}
banner.style.display = 'block';
}
// ─────────────────────────────────────────────────────────────────────────
// UTILITIES
// ─────────────────────────────────────────────────────────────────────────
// Escape HTML special characters to prevent XSS
function escHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// Human-readable relative time (e.g. "3 minutes ago")
function timeAgo(ts) {
const secs = Math.floor((Date.now() - ts) / 1000);
if (secs < 60) return 'just now';
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`;
}
// ─────────────────────────────────────────────────────────────────────────
// ENTRY POINT
// ─────────────────────────────────────────────────────────────────────────
// Always inject the sidebar widget on every page (reads only cached data)
injectSidebarWidget();
// Inject the full overlay only on the Properties page
if (isPropertiesPage()) {
injectOverlay();
}
// Re-check on hash changes (Torn is a SPA with hash-based routing)
window.addEventListener('hashchange', () => {
if (isPropertiesPage() && !document.getElementById('tpm-overlay')) {
injectOverlay();
}
});
})();