Torn Full Progress Tracker (Monthly Dashboard)

Track Torn progress across stats, crimes, gym, racing, playtime, net worth with monthly snapshots, date-range filtering, figures, tabs, and charts.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn Full Progress Tracker (Monthly Dashboard)
// @namespace    https://greasyfork.org/users/yourname
// @version      1.3
// @description  Track Torn progress across stats, crimes, gym, racing, playtime, net worth with monthly snapshots, date-range filtering, figures, tabs, and charts.
// @author       Amandus
// @license      MIT
// @match        https://www.torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  'use strict';

  // =========================
  // CONFIG
  // =========================
  const STORAGE_KEY = 'torn_progress_snapshots_v1';
  const SETTINGS_KEY = 'torn_progress_settings_v1';
  const CHART_JS_CDN = 'https://cdn.jsdelivr.net/npm/chart.js';
  const SNAPSHOT_FREQUENCY = 'daily'; // 'daily' or 'monthly'
  const DEFAULT_SECTIONS = [
    'basic',
    'stats',          // battle stats
    'personalstats',  // crimes, racing, playtime, gym totals, etc.
    'networth',       // net worth totals
    'money',          // cash, bank, points
  ];

  // =========================
  // STATE
  // =========================
  let settings = loadSettings();
  let chartInstance = null;

  // =========================
  // UTILITIES
  // =========================
  function loadSettings() {
    try {
      return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {
        apiKey: '',
        sections: DEFAULT_SECTIONS,
      };
    } catch {
      return { apiKey: '', sections: DEFAULT_SECTIONS };
    }
  }

  function saveSettings() {
    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
  }

  function loadSnapshots() {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
    } catch {
      return {};
    }
  }

  function saveSnapshots(data) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
  }

  function monthKeyFromDate(date = new Date()) {
    return date.toISOString().slice(0, 7); // YYYY-MM
  }

  function dayKeyFromDate(date = new Date()) {
    return date.toISOString().slice(0, 10); // YYYY-MM-DD
  }

  function shouldSnapshotToday(snapshots) {
    const today = dayKeyFromDate();
    return !snapshots.__daily || snapshots.__daily !== today;
  }

  function safeGet(obj, path, fallback = null) {
    try {
      const parts = path.split('.');
      let cur = obj;
      for (const p of parts) cur = cur[p];
      return cur ?? fallback;
    } catch {
      return fallback;
    }
  }

  // =========================
  // API
  // =========================
  async function fetchUserData(apiKey, sections) {
    const url = `https://api.torn.com/user/?selections=${encodeURIComponent(sections.join(','))}&key=${apiKey}`;
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload: (res) => {
          try {
            const data = JSON.parse(res.responseText);
            if (data.error) {
              reject(new Error(`API Error: ${data.error.error}`));
            } else {
              resolve(data);
            }
          } catch (e) {
            reject(e);
          }
        },
        onerror: (err) => reject(err),
      });
    });
  }

  function normalizeSnapshot(raw) {
    const now = new Date();
    const snapshot = {
      timestamp: now.toISOString(),
      monthKey: monthKeyFromDate(now),
      dayKey: dayKeyFromDate(now),

      // Basic
      level: safeGet(raw, 'level'),
      rank: safeGet(raw, 'rank'),
      faction: safeGet(raw, 'faction'),
      life: safeGet(raw, 'life.current'),
      energy: safeGet(raw, 'energy.current'),
      nerve: safeGet(raw, 'nerve.current'),

      // Money
      money: {
        cash: safeGet(raw, 'money_onhand') ?? safeGet(raw, 'money'),
        bank: safeGet(raw, 'money_bank'),
        points: safeGet(raw, 'points'),
      },

      // Networth
      networth: {
        total: safeGet(raw, 'networth.total'),
        // Optional breakdowns if available:
        // items: safeGet(raw, 'networth.items'),
        // bazaar: safeGet(raw, 'networth.bazaar'),
        // display: safeGet(raw, 'networth.display'),
      },

      // Battle stats
      stats: {
        strength: safeGet(raw, 'strength') ?? safeGet(raw, 'stats.strength'),
        defense: safeGet(raw, 'defense') ?? safeGet(raw, 'stats.defense'),
        speed: safeGet(raw, 'speed') ?? safeGet(raw, 'stats.speed'),
        dexterity: safeGet(raw, 'dexterity') ?? safeGet(raw, 'stats.dexterity'),
        total: safeGet(raw, 'stats.total'),
      },

      // Personal stats (broad coverage)
      personal: {
        crimes: {
          committed: safeGet(raw, 'personalstats.crimes'),
          success: safeGet(raw, 'personalstats.crimesuccess'),
          fail: safeGet(raw, 'personalstats.crimefail'),
          respect: safeGet(raw, 'personalstats.crimerespect'),
        },
        gym: {
          trains: safeGet(raw, 'personalstats.gymtrains'),
          energyused: safeGet(raw, 'personalstats.energyused'),
          statgain: safeGet(raw, 'personalstats.statgain'),
        },
        racing: {
          racesentered: safeGet(raw, 'personalstats.racesentered'),
          raceswon: safeGet(raw, 'personalstats.raceswon'),
          bestlap: safeGet(raw, 'personalstats.bestlap'),
        },
        playtime: {
          activity: safeGet(raw, 'personalstats.activity'),
          timeonline: safeGet(raw, 'personalstats.timeonline'),
          logins: safeGet(raw, 'personalstats.logins'),
        },
        combat: {
          attackswon: safeGet(raw, 'personalstats.attackswon'),
          attackslost: safeGet(raw, 'personalstats.attackslost'),
          defendswon: safeGet(raw, 'personalstats.defendswon'),
          defendslost: safeGet(raw, 'personalstats.defendslost'),
        },
      },
    };

    return snapshot;
  }

  function saveMonthlySnapshot(snapshots, snapshot) {
    const monthKey = snapshot.monthKey;
    snapshots[monthKey] = snapshots[monthKey] || [];
    snapshots[monthKey].push(snapshot);
    snapshots.__daily = snapshot.dayKey; // mark last snapshot day
    saveSnapshots(snapshots);
  }

  // =========================
  // UI
  // =========================
  function injectStyles() {
    GM_addStyle(`
      #tpt-panel {
        position: fixed;
        right: 20px;
        bottom: 20px;
        z-index: 99999;
        width: 480px;
        background: #1f2328;
        color: #e6edf3;
        border: 1px solid #30363d;
        border-radius: 8px;
        box-shadow: 0 8px 24px rgba(0,0,0,0.3);
        font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Noto Sans', 'Helvetica Neue', Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
      }
      #tpt-panel header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 10px 12px;
        border-bottom: 1px solid #30363d;
      }
      #tpt-panel .body {
        padding: 12px;
      }
      #tpt-panel input, #tpt-panel select, #tpt-panel button {
        background: #0d1117;
        color: #e6edf3;
        border: 1px solid #30363d;
        border-radius: 6px;
        padding: 6px 8px;
        margin: 4px 0;
      }
      #tpt-panel .grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 8px;
      }
      #tpt-panel .section {
        margin-top: 10px;
        padding-top: 10px;
        border-top: 1px dashed #30363d;
      }
      #tpt-panel .figures {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 6px;
      }
      #tpt-panel .figure {
        background: #0d1117;
        border: 1px solid #30363d;
        border-radius: 6px;
        padding: 8px;
      }
      #tpt-close {
        cursor: pointer;
        opacity: 0.8;
      }
      #tpt-close:hover { opacity: 1; }
      #tpt-panel .tabs {
        display: flex;
        gap: 6px;
        margin-top: 8px;
        flex-wrap: wrap;
      }
      #tpt-panel .tab {
        padding: 6px 10px;
        border-radius: 6px;
        border: 1px solid #30363d;
        background: #0d1117;
        cursor: pointer;
      }
      #tpt-panel .tab.active {
        background: #2b3137;
        border-color: #3b82f6;
      }
    `);
  }

  function createPanel() {
    const panel = document.createElement('div');
    panel.id = 'tpt-panel';
    panel.innerHTML = `
      <header>
        <strong>Torn Progress Tracker</strong>
        <span id="tpt-close">✕</span>
      </header>
      <div class="body">
        <div class="grid">
          <div>
            <label><small>API Key</small></label>
            <input type="password" id="tpt-api" placeholder="Enter API key" value="${settings.apiKey || ''}">
          </div>
          <div>
            <label><small>Sections</small></label>
            <input type="text" id="tpt-sections" value="${(settings.sections || DEFAULT_SECTIONS).join(',')}" title="Comma-separated selections">
          </div>
          <div>
            <label><small>Start (YYYY-MM)</small></label>
            <input type="month" id="tpt-start">
          </div>
          <div>
            <label><small>End (YYYY-MM)</small></label>
            <input type="month" id="tpt-end">
          </div>
        </div>

        <div class="tabs">
          <button class="tab active" data-tab="stats">Stats</button>
          <button class="tab" data-tab="networth">Net worth</button>
          <button class="tab" data-tab="crimes">Crimes</button>
          <button class="tab" data-tab="gym">Gym</button>
          <button class="tab" data-tab="racing">Racing</button>
          <button class="tab" data-tab="playtime">Playtime</button>
        </div>

        <div class="section">
          <canvas id="tpt-chart" height="220"></canvas>
        </div>
        <div class="section figures" id="tpt-figures"></div>

        <div style="margin-top:8px;">
          <button id="tpt-save">Save Settings</button>
          <button id="tpt-snapshot">Snapshot Now</button>
          <button id="tpt-export">Export</button>
          <button id="tpt-import">Import</button>
          <button id="tpt-clear">Clear</button>
        </div>
      </div>
    `;
    document.body.appendChild(panel);

    document.getElementById('tpt-close').addEventListener('click', () => panel.remove());
    document.getElementById('tpt-save').addEventListener('click', onSaveSettings);
    document.getElementById('tpt-snapshot').addEventListener('click', onSnapshotNow);
    document.getElementById('tpt-export').addEventListener('click', onExport);
    document.getElementById('tpt-import').addEventListener('click', onImport);
    document.getElementById('tpt-clear').addEventListener('click', onClear);

    document.getElementById('tpt-start').addEventListener('change', renderRange);
    document.getElementById('tpt-end').addEventListener('change', renderRange);

    document.querySelectorAll('.tab').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        renderRange();
      });
    });
  }

  function onSaveSettings() {
    const api = document.getElementById('tpt-api').value.trim();
    const sections = document.getElementById('tpt-sections').value.split(',').map(s => s.trim()).filter(Boolean);
    settings.apiKey = api;
    settings.sections = sections.length ? sections : DEFAULT_SECTIONS;
    saveSettings();
    renderRange();
  }

  async function onSnapshotNow() {
    if (!settings.apiKey) {
      alert('Please enter your API key.');
      return;
    }
    try {
      const raw = await fetchUserData(settings.apiKey, settings.sections);
      const snapshot = normalizeSnapshot(raw);
      const snapshots = loadSnapshots();
      saveMonthlySnapshot(snapshots, snapshot);
      renderRange();
    } catch (e) {
      console.error(e);
      alert(`Snapshot failed: ${e.message}`);
    }
  }

  function onExport() {
    const data = loadSnapshots();
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `torn_progress_${Date.now()}.json`;
    a.click();
    URL.revokeObjectURL(url);
  }

  function onImport() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'application/json';
    input.onchange = () => {
      const file = input.files[0];
      const reader = new FileReader();
      reader.onload = () => {
        try {
          const data = JSON.parse(reader.result);
          saveSnapshots(data);
          renderRange();
        } catch {
          alert('Invalid JSON file.');
        }
      };
      reader.readAsText(file);
    };
    input.click();
  }

  function onClear() {
    if (confirm('Clear all stored snapshots?')) {
      saveSnapshots({});
      renderRange();
    }
  }

  function ensureChartJs(callback) {
    if (typeof Chart !== 'undefined') {
      callback();
      return;
    }
    const script = document.createElement('script');
    script.src = CHART_JS_CDN;
    script.onload = callback;
    document.head.appendChild(script);
  }

  function computeGain(arr) {
    if (!arr.length || arr[0] == null || arr[arr.length - 1] == null) return null;
    return arr[arr.length - 1] - arr[0];
  }

  function renderFigures(figs) {
    const container = document.getElementById('tpt-figures');
    container.innerHTML = '';
    figs.forEach(f => {
      const div = document.createElement('div');
      div.className = 'figure';
      div.innerHTML = `<div style="font-size:12px;opacity:0.8;">${f.label}</div><div style="font-weight:600;">${f.value ?? '—'}</div>`;
      container.appendChild(div);
    });
  }

  function renderRange() {
    const start = document.getElementById('tpt-start').value || '';
    const end = document.getElementById('tpt-end').value || '';
    const snapshots = loadSnapshots();
    const months = Object.keys(snapshots)
      .filter(k => k !== '__daily')
      .filter(k => (!start || k >= start) && (!end || k <= end))
      .sort();

    const tab = document.querySelector('.tab.active').dataset.tab;

    // Collect monthly end-of-month values
    const labels = [];
    const series = {
      strength: [],
      defense: [],
      speed: [],
      dexterity: [],
      networth: [],
      crimesCommitted: [],
      crimesSuccess: [],
      crimesFail: [],
      gymTrains: [],
      gymStatGain: [],
      gymEnergyUsed: [],
      racesEntered: [],
      racesWon: [],
      bestLap: [],
      timeOnline: [],
      logins: [],
      activity: [],
    };

    months.forEach(m => {
      const arr = snapshots[m] || [];
      if (!arr.length) return;
      const last = arr[arr.length - 1];
      labels.push(m);

      series.strength.push(last.stats.strength ?? null);
      series.defense.push(last.stats.defense ?? null);
      series.speed.push(last.stats.speed ?? null);
      series.dexterity.push(last.stats.dexterity ?? null);

      series.networth.push(last.networth.total ?? null);

      series.crimesCommitted.push(last.personal.crimes.committed ?? null);
      series.crimesSuccess.push(last.personal.crimes.success ?? null);
      series.crimesFail.push(last.personal.crimes.fail ?? null);

      series.gymTrains.push(last.personal.gym.trains ?? null);
      series.gymStatGain.push(last.personal.gym.statgain ?? null);
      series.gymEnergyUsed.push(last.personal.gym.energyused ?? null);

      series.racesEntered.push(last.personal.racing.racesentered ?? null);
      series.racesWon.push(last.personal.racing.raceswon ?? null);
      series.bestLap.push(last.personal.racing.bestlap ?? null);

      series.timeOnline.push(last.personal.playtime.timeonline ?? null);
      series.logins.push(last.personal.playtime.logins ?? null);
      series.activity.push(last.personal.playtime.activity ?? null);
    });

    // Figures per tab
    let figs = [];
    let datasets = [];
    let type = 'line';
    let options = {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: 'index', intersect: false },
      plugins: { legend: { position: 'bottom' }, tooltip: { enabled: true } },
      scales: { y: { title: { display: true, text: '' } } },
    };

    if (tab === 'stats') {
      type = 'line';
      datasets = [
        { label: 'Strength', data: series.strength, borderColor: '#ef4444', tension: 0.2 },
        { label: 'Defense', data: series.defense, borderColor: '#3b82f6', tension: 0.2 },
        { label: 'Speed', data: series.speed, borderColor: '#f59e0b', tension: 0.2 },
        { label: 'Dexterity', data: series.dexterity, borderColor: '#22c55e', tension: 0.2 },
      ];
      figs = [
        { label: 'Months covered', value: labels.length },
        { label: 'Strength gain', value: computeGain(series.strength) },
        { label: 'Defense gain', value: computeGain(series.defense) },
        { label: 'Speed gain', value: computeGain(series.speed) },
        { label: 'Dexterity gain', value: computeGain(series.dexterity) },
      ];
      options.scales.y.title.text = 'Battle stats';
    } else if (tab === 'networth') {
      type = 'line';
      datasets = [
        { label: 'Net worth', data: series.networth, borderColor: '#a855f7', tension: 0.2 },
      ];
      figs = [
        { label: 'Months covered', value: labels.length },
        { label: 'Net worth change', value: computeGain(series.networth) },
      ];
      options.scales.y.title.text = 'Net worth';
    } else if (tab === 'crimes') {
      type = 'bar';
      datasets = [
        { label: 'Committed', data: series.crimesCommitted, backgroundColor: '#3b82f6', borderColor: '#3b82f6' },
        { label: 'Success', data: series.crimesSuccess, backgroundColor: '#22c55e', borderColor: '#22c55e' },
        { label: 'Fail', data: series.crimesFail, backgroundColor: '#ef4444', borderColor: '#ef4444' },
      ];
      figs = [
        { label: 'Months covered', value: labels.length },
        { label: 'Committed (Δ)', value: computeGain(series.crimesCommitted) },
        { label: 'Success (Δ)', value: computeGain(series.crimesSuccess) },
        { label: 'Fail (Δ)', value: computeGain(series.crimesFail) },
      ];
      options.scales.y.title.text = 'Crimes totals';
    } else if (tab === 'gym') {
      type = 'bar';
      datasets = [
        { label: 'Trains', data: series.gymTrains, backgroundColor: '#3b82f6', borderColor: '#3b82f6' },
        { label: 'Stat gain', data: series.gymStatGain, backgroundColor: '#22c55e', borderColor: '#22c55e' },
        { label: 'Energy used', data: series.gymEnergyUsed, backgroundColor: '#f59e0b', borderColor: '#f59e0b' },
      ];
      figs = [
        { label: 'Months covered', value: labels.length },
        { label: 'Trains (Δ)', value: computeGain(series.gymTrains) },
        { label: 'Stat gain (Δ)', value: computeGain(series.gymStatGain) },
        { label: 'Energy used (Δ)', value: computeGain(series.gymEnergyUsed) },
      ];
      options.scales.y.title.text = 'Gym totals';
    } else if (tab === 'racing') {
      type = 'bar';
      datasets = [
        { label: 'Races entered', data: series.racesEntered, backgroundColor: '#3b82f6', borderColor: '#3b82f6' },
        { label: 'Races won', data: series.racesWon, backgroundColor: '#22c55e', borderColor: '#22c55e' },
      ];
      figs = [
        { label: 'Months covered', value: labels.length },
        { label: 'Entered (Δ)', value: computeGain(series.racesEntered) },
        { label: 'Won (Δ)', value: computeGain(series.racesWon) },
        { label: 'Best lap (latest)', value: series.bestLap.length ? series.bestLap[series.bestLap.length - 1] : null },
      ];
      options.scales.y.title.text = 'Racing totals';
    } else if (tab === 'playtime') {
      type = 'bar';
      datasets = [
        { label: 'Time online', data: series.timeOnline, backgroundColor: '#3b82f6', borderColor: '#3b82f6' },
        { label: 'Logins', data: series.logins, backgroundColor: '#22c55e', borderColor: '#22c55e' },
        { label: 'Activity', data: series.activity, backgroundColor: '#f59e0b', borderColor: '#f59e0b' },
      ];
      figs = [
        { label: 'Months covered', value: labels.length },
        { label: 'Time online (Δ)', value: computeGain(series.timeOnline) },
        { label: 'Logins (Δ)', value: computeGain(series.logins) },
        { label: 'Activity (Δ)', value: computeGain(series.activity) },
      ];
      options.scales.y.title.text = 'Playtime totals';
    }

    renderFigures(figs);

    ensureChartJs(() => {
      const ctx = document.getElementById('tpt-chart').getContext('2d');
      if (chartInstance) chartInstance.destroy();
      chartInstance = new Chart(ctx, {
        type,
        data: { labels, datasets },
        options,
      });
    });
  }

  // =========================
  // BOOTSTRAP
  // =========================
  async function init() {
    injectStyles();
    createPanel();

    // Auto snapshot (daily or monthly)
    const snapshots = loadSnapshots();
    if (SNAPSHOT_FREQUENCY === 'daily') {
      if (shouldSnapshotToday(snapshots) && settings.apiKey) {
        try {
          const raw = await fetchUserData(settings.apiKey, settings.sections);
          const snap = normalizeSnapshot(raw);
          saveMonthlySnapshot(snapshots, snap);
        } catch (e) {
          console.warn('Auto snapshot failed:', e.message);
        }
      }
    } else {
      // Monthly: only snapshot if month has no entries yet
      const monthKey = monthKeyFromDate();
      if ((!snapshots[monthKey] || !snapshots[monthKey].length) && settings.apiKey) {
        try {
          const raw = await fetchUserData(settings.apiKey, settings.sections);
          const snap = normalizeSnapshot(raw);
          saveMonthlySnapshot(snapshots, snap);
        } catch (e) {
          console.warn('Auto snapshot failed:', e.message);
        }
      }
    }

    renderRange();
  }

  const readyInterval = setInterval(() => {
    if (document.body) {
      clearInterval(readyInterval);
      init();
    }
  }, 500);
})();