Torn: Time → TCT

Time Converter widget (bottom-left): TCT & your timezone clocks + Any→TCT and TCT→YourTZ converters. Clean corners, compact, themed.

// ==UserScript==
// @name         Torn: Time → TCT
// @namespace    Njoric
// @version      1.6.6
// @description  Time Converter widget (bottom-left): TCT & your timezone clocks + Any→TCT and TCT→YourTZ converters. Clean corners, compact, themed.
// @license      MIT
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @require      https://cdnjs.cloudflare.com/ajax/libs/luxon/3.4.4/luxon.min.js
// ==/UserScript==

(function () {
  'use strict';
  const { DateTime } = luxon;

  const KEY = 'tctWidget.settings.v1';
  const defaults = { myTZ: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', collapsed: false };
  const load = () => { try { return Object.assign({}, defaults, JSON.parse(localStorage.getItem(KEY)||'{}')); } catch { return {...defaults}; } };
  const save = (s) => localStorage.setItem(KEY, JSON.stringify(s));
  let S = load();

  GM_addStyle(`
    .tctw-wrap, .tctw-wrap * { box-sizing: border-box; }
    .tctw-wrap{
      position:fixed; left:10px; bottom:10px; z-index:99999; width:300px;
      background:#191919; border:1px solid #bf542f; border-radius:10px; color:#48711e;
      font:12px/1.25 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
      box-shadow:0 10px 24px rgba(0,0,0,.45);
      overflow:hidden; /* Fix: clip inner content to rounded edges */
    }
    .tctw-hdr{
      display:flex; align-items:center; justify-content:space-between;
      padding:8px 10px; border-bottom:1px solid #bf542f; background:#191919;
      border-top-left-radius:10px;  /* Fix: match container curve */
      border-top-right-radius:10px; /* Fix: match container curve */
    }
    .tctw-title{ font-weight:700; letter-spacing:.2px }
    .tctw-btn{
      cursor:pointer; user-select:none; padding:3px 8px; font-size:11px;
      background:#333333; border:1px solid #bf542f; border-radius:6px; color:#48711e;
      display:inline-block; line-height:1;
    }
    .tctw-btn:hover{ background:#2b2b2b }
    .tctw-btn:focus,
    .tctw-sel:focus,
    .tctw-input:focus { outline:none; }

    .tctw-body{ padding:10px }
    .tctw-grid{ display:grid; grid-template-columns:1fr; gap:8px }

    .tctw-box{
      background:#333333; border:1px solid #bf542f; border-radius:8px; padding:8px;
      overflow:hidden;
    }
    .tctw-label{ opacity:.9; font-weight:600; font-size:11px; text-transform:uppercase; margin:0 0 4px 0 }
    .tctw-time{ font-variant-numeric:tabular-nums; font-weight:700; font-size:16px; margin:0 }
    .tctw-sub{ opacity:.7; font-size:11px; margin-top:2px }

    .tctw-sel,.tctw-input,.tctw-out{
      display:block; width:100%; margin:0;
      border-radius:6px; border:1px solid #bf542f;
      background:#191919; color:#48711e; font-size:12px;
    }
    .tctw-sel,.tctw-input{ padding:5px 8px; }
    .tctw-out{ padding:6px 8px; border-style:dashed; margin-top:4px; word-break:break-word; font-variant-numeric:tabular-nums }
    .tctw-sel:focus,.tctw-input:focus{
      border-color:#61d0d7; box-shadow:0 0 0 2px rgba(84,161,230,.15)
    }

    .tctw-help{ opacity:.7; font-size:11px; margin-top:4px }
    .tctw-collapsed .tctw-body{ display:none }
  `);

  const root = document.createElement('div');
  root.className = 'tctw-wrap' + (S.collapsed ? ' tctw-collapsed':'');
  root.innerHTML = `
    <div class="tctw-hdr">
      <div class="tctw-title">Time Converter</div>
      <div><span class="tctw-btn" id="tctw-collapse">${S.collapsed ? 'Expand' : 'Collapse'}</span></div>
    </div>
    <div class="tctw-body">
      <div class="tctw-grid">
        <div class="tctw-box">
          <div class="tctw-label">TCT now</div>
          <div class="tctw-time" id="tctw-tct-now">--:--:--</div>
          <div class="tctw-sub" id="tctw-tct-date">--/--/--</div>
        </div>
        <div class="tctw-box">
          <div class="tctw-label">My timezone</div>
          <select class="tctw-sel" id="tctw-tz"></select>
          <div class="tctw-time" id="tctw-local-now">--:--:--</div>
          <div class="tctw-sub" id="tctw-local-date">--/--/--</div>
        </div>
        <div class="tctw-box">
          <div class="tctw-label">Any → TCT</div>
          <input class="tctw-input" id="tctw-any" placeholder="e.g. 10:00 CST | 1000 CST | 7am PST | 2025-09-16 08:00 CEST">
          <div class="tctw-help">If date is omitted, today (in the source TZ) is assumed.</div>
          <div class="tctw-out" id="tctw-any-out"></div>
        </div>
        <div class="tctw-box">
          <div class="tctw-label">TCT → My TZ</div>
          <input class="tctw-input" id="tctw-tct-in" placeholder="TCT time, e.g. 11:00 or 2025-09-16 11:00">
          <div class="tctw-help">Enter TCT (UTC+0). If date is omitted, today (UTC) is used.</div>
          <div class="tctw-out" id="tctw-tct-out"></div>
        </div>
      </div>
    </div>
  `;
  document.body.appendChild(root);

  const el = {
    collapse: root.querySelector('#tctw-collapse'),
    tz: root.querySelector('#tctw-tz'),
    tctNow: root.querySelector('#tctw-tct-now'),
    tctDate: root.querySelector('#tctw-tct-date'),
    localNow: root.querySelector('#tctw-local-now'),
    localDate: root.querySelector('#tctw-local-date'),
    anyIn: root.querySelector('#tctw-any'),
    anyOut: root.querySelector('#tctw-any-out'),
    tctIn: root.querySelector('#tctw-tct-in'),
    tctOut: root.querySelector('#tctw-tct-out')
  };

  const COMMON_TZS = [
    'UTC','Europe/London','Europe/Berlin','Europe/Helsinki',
    'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
    'America/Phoenix','America/Anchorage','Pacific/Honolulu',
    'Asia/Kolkata','Asia/Singapore','Asia/Hong_Kong','Asia/Tokyo','Asia/Seoul',
    'Australia/Sydney','Australia/Adelaide','Australia/Perth','Pacific/Auckland'
  ];
  const uniq = (a) => Array.from(new Set(a));
  const tzOptions = uniq([S.myTZ, Intl.DateTimeFormat().resolvedOptions().timeZone, ...COMMON_TZS]).filter(Boolean);
  el.tz.innerHTML = tzOptions.map(z => `<option value="${z}">${z}</option>`).join('');
  el.tz.value = S.myTZ;

  const fmtTCT = (dt) => dt.setZone('UTC').toFormat('HH:mm:ss');
  const fmtDateTCT = (dt) => dt.setZone('UTC').toFormat('EEE dd/MM/yy');
  const fmtLocal = (dt,z) => dt.setZone(z).toFormat('HH:mm:ss');
  const fmtDateLocal = (dt,z) => dt.setZone(z).toFormat('EEE dd/MM/yy');

  function tick(){
    const now = DateTime.now();
    el.tctNow.textContent = fmtTCT(now);
    el.tctDate.textContent = fmtDateTCT(now);
    el.localNow.textContent = fmtLocal(now, S.myTZ);
    el.localDate.textContent = fmtDateLocal(now, S.myTZ);
  }
  tick();
  setInterval(tick, 1000);

  el.collapse.addEventListener('click', () => {
    S.collapsed = !S.collapsed;
    root.classList.toggle('tctw-collapsed', S.collapsed);
    el.collapse.textContent = S.collapsed ? 'Expand' : 'Collapse';
    save(S);
  });

  // --- Timezone abbreviations ---
  const TZ_ABBR = { UTC:'UTC', GMT:'UTC', EST:'America/New_York', EDT:'America/New_York',
    CST:'America/Chicago', CDT:'America/Chicago', MST:'America/Denver', MDT:'America/Denver',
    PST:'America/Los_Angeles', PDT:'America/Los_Angeles', AKST:'America/Anchorage',
    AKDT:'America/Anchorage', HST:'Pacific/Honolulu', CET:'Europe/Berlin', CEST:'Europe/Berlin',
    EET:'Europe/Helsinki', EEST:'Europe/Helsinki', BST:'Europe/London', IST:'Asia/Kolkata',
    SGT:'Asia/Singapore', HKT:'Asia/Hong_Kong', JST:'Asia/Tokyo', KST:'Asia/Seoul',
    AEST:'Australia/Sydney', AEDT:'Australia/Sydney', ACST:'Australia/Adelaide',
    ACDT:'Australia/Adelaide', AWST:'Australia/Perth', NZST:'Pacific/Auckland',
    NZDT:'Pacific/Auckland', SAST:'Africa/Johannesburg', AZT:'America/Phoenix' };
  const resolveTZ = (t) => { if (!t) return null; const k=t.toUpperCase(); if (TZ_ABBR[k]) return TZ_ABBR[k]; if (/^[A-Za-z]+\/[A-Za-z_]+$/.test(t)) return t; return null; };

  function parseAnyToUTC(query) {
    const raw = (query || '').trim().replace(/\s+/g, ' ');
    if (!raw) return { ok:false, err:'Type a time and timezone (e.g., "10:00 CST", "1000 CST")' };

    const parts = raw.split(' ');
    let tzToken = parts[parts.length - 1];
    let tz = resolveTZ(tzToken);
    let rest = parts.slice(0, parts.length - 1).join(' ');

    if (!tz) {
      const iana = parts.find(p => p.includes('/'));
      if (iana) { tz = iana; rest = parts.filter(p => p !== iana).join(' '); }
    }
    if (!tz) return { ok:false, err:`Could not recognize timezone in "${raw}".` };

    let dateStr = null;
    let timeStr = rest.trim();
    const m = timeStr.match(/^(\d{4}[-/]\d{2}[-/]\d{2})\s+(.+)$/);
    if (m) { dateStr = m[1].replace(/\//g, '-'); timeStr = m[2]; }

    timeStr = timeStr.replace(/(\d)(am|pm)$/i, '$1 $2');
    if (/^\d{3,4}$/.test(timeStr)) {
      const s = timeStr.padStart(4, '0');
      const hh = parseInt(s.slice(0, -2), 10), mm = parseInt(s.slice(-2), 10);
      if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) timeStr = `${hh}:${String(mm).padStart(2,'0')}`;
    }
    if (/^\d{1,2}(\s*[ap]m)?$/i.test(timeStr)) timeStr = timeStr.replace(/^(\d{1,2})(?:\s*([ap]m))?$/i, '$1:00$2');

    const formats = ['H:mm','h:mm a','H','h a','H:mm:ss','h:mm:ss a'];
    const base = dateStr ? DateTime.fromISO(dateStr, { zone: tz }) : DateTime.now().setZone(tz);

    let parsed = null;
    for (const f of formats) {
      const t = DateTime.fromFormat(timeStr, f, { zone: tz });
      if (t.isValid) { parsed = base.set({ hour: t.hour, minute: t.minute, second: t.second }); break; }
    }
    if (!parsed) {
      const t = DateTime.fromISO(timeStr, { zone: tz });
      if (t.isValid) parsed = t;
    }
    if (!parsed || !parsed.isValid) return { ok:false, err:`Could not parse the time part "${timeStr}".` };

    const dtUTC = parsed.setZone('UTC');
    return { ok:true, dtUTC };
  }

  function parseTCTtoMyTZ(query, myTZ) {
    const raw = (query || '').trim();
    if (!raw) return { ok:false, err:'Enter a TCT time like "11:00" or "2025-09-16 11:00"' };

    const nowUTC = DateTime.now().setZone('UTC');
    let parsed = DateTime.fromFormat(raw, 'H:mm', { zone:'UTC' });
    if (!parsed.isValid) parsed = DateTime.fromFormat(raw, 'H:mm:ss', { zone:'UTC' });
    if (!parsed.isValid) parsed = DateTime.fromISO(raw, { zone:'UTC' });
    if (!parsed.isValid) return { ok:false, err:'Use "HH:mm", "HH:mm:ss", or "YYYY-MM-DD HH:mm".' };

    if (!/^\d{4}-\d{2}-\d{2}/.test(raw)) {
      parsed = nowUTC.set({ hour: parsed.hour, minute: parsed.minute, second: parsed.second });
    }
    const local = parsed.setZone(myTZ);
    return { ok:true, local };
  }

  const runAnyToTCT = () => {
    const q = el.anyIn.value;
    if (!q) { el.anyOut.textContent = ''; return; }
    const res = parseAnyToUTC(q);
    el.anyOut.textContent = res.ok ? `TCT: ${res.dtUTC.toFormat("EEE HH:mm - dd/MM/yy")}` : res.err;
  };
  const runTCTtoMyTZ = () => {
    const q = el.tctIn.value;
    if (!q) { el.tctOut.textContent = ''; return; }
    const res = parseTCTtoMyTZ(q, S.myTZ);
    el.tctOut.textContent = res.ok ? res.local.toFormat(`EEE HH:mm - dd/MM/yy '(${S.myTZ})'`) : res.err;
  };

  el.tz.addEventListener('change', () => { S.myTZ = el.tz.value; save(S); tick(); runTCTtoMyTZ(); });
  el.anyIn.addEventListener('input', runAnyToTCT);
  el.tctIn.addEventListener('input', runTCTtoMyTZ);

  document.addEventListener('keydown', (e) => {
    if (e.altKey && (e.key === 't' || e.key === 'T')) {
      if (S.collapsed) {
        S.collapsed = false; root.classList.remove('tctw-collapsed'); el.collapse.textContent = 'Collapse'; save(S);
        setTimeout(()=> el.anyIn.focus(), 0);
      } else {
        el.anyIn.focus();
      }
    }
  });
})();