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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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

})();