Torn Property Manager

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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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 = `&nbsp;<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 = `&nbsp;<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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
    }

    // 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();
        }
    });

})();