Torn Search Helper

Show hospital time live with attack button on user search page.

// ==UserScript==
// @name         Torn Search Helper
// @namespace    torn-search-helper
// @version      1.8
// @description  Show hospital time live with attack button on user search page.
// @author       Rick-Grimes
// @match        https://www.torn.com/page.php?sid=UserList*
// @match        https://www.torn.com/loader.php?sid=UserList*
// @license      MIT
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
  "use strict";

  console.log("[TornSearchHelper] loaded");

  // --- configuration & key storage ---
  const STORAGE_KEY = "tsh-apikey-v1";
  let apiKey = localStorage.getItem(STORAGE_KEY) ?? "";

  try {
    GM_registerMenuCommand("Torn Search Helper: Set API Key", () => {
      const v = prompt("Enter your PUBLIC Torn API key (16 chars) — leave empty to disable API usage:", apiKey || "");
      if (v === null) return;
      if (v === "" || v.length === 16) {
        apiKey = v;
        localStorage.setItem(STORAGE_KEY, apiKey);
        alert("API key saved.");
      } else alert("API key must be 16 characters or blank.");
    });
  } catch (e) {
    console.warn("[TornSearchHelper] GM_registerMenuCommand unavailable:", e);
  }

  // --- styles ---
  GM_addStyle(`
.tsh-status { display:inline-block; margin-left:6px; font-size:12px; font-weight:600; color:#f39c12; white-space:nowrap; text-shadow:0 1px 0 rgba(0,0,0,0.6);}
.tsh-status.urgent { color:#ff6b6b; }
.tsh-status.small { font-weight:500; color:#bdbdbd; }
.tsh-attack { display:inline-block; margin-left:6px; padding:2px 6px; font-size:11px; font-weight:700; color:#fff; background:#e74c3c; border-radius:3px; cursor:pointer; text-decoration:none; vertical-align:middle; line-height:1; box-shadow:0 1px 0 rgba(0,0,0,0.2);}
.tsh-attack:hover { filter: brightness(0.95); }
.tsh-attack:active { transform: translateY(1px); }
.tsh-wrap { display:inline-flex; align-items:center; gap:6px; }
`);

  // --- helpers ---
  function qAll(sel, root = document) { return Array.from(root.querySelectorAll(sel)); }
  function q(sel, root = document) { return root.querySelector(sel); }
  function pad(n){ return String(n).padStart(2,"0"); }
  function formatRemaining(seconds){
    if(seconds<=0) return "00:00:00";
    const h=Math.floor(seconds/3600), m=Math.floor((seconds%3600)/60), s=seconds%60;
    return `${pad(h)}:${pad(m)}:${pad(s)}`;
  }

  const lastUpdated = new Map();
  const UPDATE_INTERVAL_MS = 10_000;

  function collectRows(){
    let listItems = qAll("ul.user-info-list-wrap li[class^='user'], ul.user-info-list-wrap li[class*='user']");
    if(listItems.length===0){
      return qAll("ul.user-info-list-wrap li").filter(li=>li.querySelector("a[href*='profiles.php?XID=']"));
    }
    return listItems;
  }

  function extractUserFromLi(li){
    const a = li.querySelector("a[href*='profiles.php?XID=']");
    if(!a) return null;
    const id = a.href.match(/XID=(\d+)/)?.[1];
    return id ? {id, nameEl:a, li, expander: li.querySelector("div.expander")||li.querySelector(".expander")} : null;
  }

  function ensureNodes(user){
    const li = user.li;
    let iconsWrap = q(".icons-wrap", li) || q("ul.big.svg", li);
    if(!iconsWrap) iconsWrap = user.expander; if(!iconsWrap) return null;

    let wrapper = iconsWrap.nextElementSibling;
    if(!wrapper || !wrapper.classList?.contains("tsh-wrap")){
      wrapper = document.createElement("span"); wrapper.className="tsh-wrap";
      iconsWrap.parentNode.insertBefore(wrapper, iconsWrap.nextSibling);
    }

    let timerNode = wrapper.querySelector(".tsh-status");
    if(!timerNode){ timerNode=document.createElement("span"); timerNode.className="tsh-status"; wrapper.appendChild(timerNode); }

    let attackNode = wrapper.querySelector(".tsh-attack");
    if(!attackNode){
      attackNode=document.createElement("a");
      attackNode.className="tsh-attack"; attackNode.textContent="Attack"; attackNode.href="#"; attackNode.setAttribute("role","button");
      attackNode.addEventListener("click",(ev)=>{ ev.preventDefault(); if(attackNode.dataset.attackHref) window.open(attackNode.dataset.attackHref,"_blank"); },{passive:false});
      wrapper.appendChild(attackNode);
    }

    return {wrapper, timerNode, attackNode};
  }

  function parseFallback(li){
    const iconLis=qAll(".icons-wrap li, ul.big.svg li", li);
    for(const icon of iconLis){
      const title = icon.getAttribute("title")||"";
      if(/Hospital|Jail|Traveling|Abroad/i.test(title)){
        const timeMatch = title.match(/data-time=['"]?(\d+)/i);
        if(timeMatch) return {state:"Hospital", untilInSeconds:parseInt(timeMatch[1],10)};
        const timerSpan=icon.querySelector(".timer");
        if(timerSpan){
          const dt = timerSpan.getAttribute("data-time");
          if(dt && /^\d+$/.test(dt)) return {state:"Hospital", untilInSeconds:parseInt(dt,10)};
          return {state:"Hospital", text:timerSpan.textContent.trim()||title};
        }
        return {state:title.replace(/<[^>]+>/g,"").replace(/\s+/g," ").trim()};
      }
    }
    return null;
  }

  async function fetchStatus(id){
    if(!apiKey) return null;
    try{
      const resp = await fetch(`https://api.torn.com/user/${id}?selections=basic&key=${apiKey}`);
      const json = await resp.json();
      if(!json || json.error) return null;
      return json.status||null;
    }catch(e){ console.error(e); return null; }
  }

  async function updateOne(user){
    const now = Date.now();
    if(now - (lastUpdated.get(user.id)||0)<UPDATE_INTERVAL_MS) return;
    lastUpdated.set(user.id, now);

    const nodes = ensureNodes(user); if(!nodes) return;
    const {timerNode, attackNode} = nodes;
    timerNode.classList.remove("urgent","small"); timerNode.textContent="";

    attackNode.dataset.attackHref=`https://www.torn.com/loader.php?sid=attack&user2ID=${user.id}`;

    let status = apiKey ? await fetchStatus(user.id) : null;
    let st = null;

    if(!status){
      const fallback=parseFallback(user.li);
      if(fallback){
        if(fallback.untilInSeconds!==undefined){
          const untilUnix=Math.floor(Date.now()/1000)+fallback.untilInSeconds;
          timerNode.dataset.until=untilUnix;
          timerNode.textContent=formatRemaining(fallback.untilInSeconds);
          if(fallback.untilInSeconds<300) timerNode.classList.add("urgent");
          st=fallback.state;
        } else if(fallback.text){ timerNode.textContent=fallback.text; timerNode.classList.add("small"); st=fallback.state; }
        else{ timerNode.textContent=String(fallback.state).slice(0,30); timerNode.classList.add("small"); st=fallback.state; }
      } else timerNode.textContent="";
    } else{
      st=status.state||"Unknown";
      if(st==="Hospital"||st==="Jail"){
        const nowSec=Math.floor(Date.now()/1000);
        const until=parseInt(status.until||0,10);
        const remaining=Math.max(0,until-nowSec);
        timerNode.dataset.until=until;
        timerNode.textContent=formatRemaining(remaining);
        if(remaining<300) timerNode.classList.add("urgent");
      } else if(st==="Traveling"||st==="Abroad"){
        let desc=status.description||st;
        let out=desc;
        const m=desc.match(/Traveling to (.+)/i); if(m) out=`► ${m[1].slice(0,30)}`;
        const m2=desc.match(/In (.+)/i); if(m2) out=`${m2[1].slice(0,30)}`;
        timerNode.textContent=out; timerNode.classList.add("small");
      } else{ timerNode.textContent=st; timerNode.classList.add("small"); }
    }

    // Show attack button only if not in Jail/Federal Jail/Traveling/Abroad
    attackNode.style.display = /Jail|Traveling|Abroad/i.test(st) ? "none" : "inline-block";
  }

  async function updateVisibleSet(){
    const rows=collectRows();
    if(!rows || !rows.length) return;
    const users=[];
    rows.forEach(li=>{ const u=extractUserFromLi(li); if(u) users.push(u); });
    users.forEach(u=>ensureNodes(u));
    for(const u of users){ updateOne(u); await new Promise(r=>setTimeout(r,120)); }
  }

  function tickTimers(){
    const nodes=qAll(".tsh-status"); const nowSec=Math.floor(Date.now()/1000);
    nodes.forEach(node=>{
      if(!node.dataset.until) return;
      const until=parseInt(node.dataset.until,10);
      const remaining=Math.max(0,until-nowSec);
      node.textContent=formatRemaining(remaining);
      node.classList.toggle("urgent",remaining<300);
    });
  }

  setTimeout(()=>updateVisibleSet(),1200);

  const listWrap=document.querySelector("ul.user-info-list-wrap");
  const observerTarget=listWrap||document.body;
  const mo=new MutationObserver(muts=>{ if(muts.some(m=>m.addedNodes.length>0)){ if(window.__tsh_update_timer) clearTimeout(window.__tsh_update_timer); window.__tsh_update_timer=setTimeout(()=>updateVisibleSet(),300); } });
  mo.observe(observerTarget,{childList:true,subtree:true});

  setInterval(()=>{ tickTimers(); updateVisibleSet(); },1000);

  console.log("[TornSearchHelper] initialized (accurate server-synced timers + Attack button logic)");
})();