Tracks drug use, overdoses, addiction points, and company addiction between rehabs
// ==UserScript==
// @name Torn Addiction Tracker
// @namespace torn.addiction.tracker
// @version 2.0.6
// @description Tracks drug use, overdoses, addiction points, and company addiction between rehabs
// @author xaeksx
// @match https://www.torn.com/*
// @grant GM.addStyle
// @run-at document-start
// @license MIT
// @supportURL https://www.torn.com/forums.php#/p=forums&f=67
// @homepageURL https://greasyfork.org
// ==/UserScript==
/*
┌─────────────────────────────────────────────────────────────────────────┐
│ API TERMS OF SERVICE DISCLOSURE (required by Torn API ToS guidelines) │
├──────────────────┬──────────────────────────────────────────────────────┤
│ Data Storage │ Only locally (browser localStorage on your device). │
│ │ Nothing is stored on any external server. │
├──────────────────┼──────────────────────────────────────────────────────┤
│ Data Sharing │ Nobody. Data never leaves your browser. │
├──────────────────┼──────────────────────────────────────────────────────┤
│ Purpose of Use │ Personal gain — track own drug usage and estimate │
│ │ Company Addiction impact between rehabs. │
├──────────────────┼──────────────────────────────────────────────────────┤
│ Key Storage │ Stored only in your browser's localStorage. │
│ & Sharing │ Never transmitted anywhere except api.torn.com. │
├──────────────────┼──────────────────────────────────────────────────────┤
│ Key Access Level │ Limited Access (minimum required). │
│ │ Selections used: user → personalstats, events │
│ │ Perk setting: Addiction XXV faction perk (0–50%) │
└──────────────────┴──────────────────────────────────────────────────────┘
API KEY SETUP:
- Go to: Torn → Settings → API (torn.com/preferences.php#tab=api)
- Click "Add new key"
- Set access level to "Limited Access" (minimum needed for personalstats + events)
- Give it a recognisable name, e.g. "Addiction Tracker"
- Paste the key into this script's Settings panel (⚙ button on the floating widget)
- IMPORTANT: Use a dedicated key for this script, NOT your Full Access key.
That way you can revoke it independently if needed.
NOTES:
- On first install, your current drug totals become the baseline (drugs before
install won't count). Use "Manual Rehab Reset" in settings if needed.
- Overdose detection is event-based. OD events are scanned incrementally each
poll, so they won't be missed even if they happen between polls.
- Only Xanax (+100 AP) and Ecstasy (+20 AP) overdoses contribute extra AP.
- API field names are based on Torn API v1 personalstats. If a drug always
shows 0, verify field names at:
https://api.torn.com/user/?selections=personalstats&key=YOUR_KEY
Known uncertain field: Love Juice → 'lovtaken' (may differ — check if 0).
*/
'use strict';
// ─────────────────────────────────────────────────────────────────────────────
// GM SHIM (TornPDA / Greasemonkey compatibility)
// ─────────────────────────────────────────────────────────────────────────────
try {
if (typeof GM === 'undefined') window.GM = {};
if (typeof GM.addStyle === 'undefined') {
GM.addStyle = (css) => {
const s = document.createElement('style');
s.textContent = css;
document.head.appendChild(s);
};
}
} catch {}
// ─────────────────────────────────────────────────────────────────────────────
// DRUG DEFINITIONS
// Each drug has: a display label, AP value, and the personalstats API field name.
// ─────────────────────────────────────────────────────────────────────────────
const DRUGS = [
{ id: 'xanax', label: 'Xanax', ap: 35, field: 'xantaken' },
{ id: 'lovejuice', label: 'Love Juice', ap: 50, field: 'lovtaken' }, // ⚠ verify field
{ id: 'pcp', label: 'PCP', ap: 26, field: 'pcptaken' },
{ id: 'ecstasy', label: 'Ecstasy', ap: 20, field: 'exttaken' },
{ id: 'lsd', label: 'LSD', ap: 20, field: 'lsdtaken' },
{ id: 'speed', label: 'Speed', ap: 14, field: 'spetaken' },
{ id: 'vicodin', label: 'Vicodin', ap: 13, field: 'victaken' },
{ id: 'opium', label: 'Opium', ap: 10, field: 'opitaken' },
{ id: 'ketamine', label: 'Ketamine', ap: 8, field: 'kettaken' },
{ id: 'shrooms', label: 'Shrooms', ap: 6, field: 'shrtaken' },
{ id: 'cannabis', label: 'Cannabis', ap: 1, field: 'cantaken' },
];
// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────────────────────────
const DECAY_AP = 20; // AP subtracted at 5 TCT every day
const DECAY_HOUR = 3; // 03:00 UTC = 3 TCT
const CA_HOUR = 18; // 18:00 UTC = 18 TCT (CA is calculated here)
const AP_PER_CA = 8; // 8 AP = −1 Company Addiction point
const POLL_MS = 30000; // personalstats every 30 seconds (uses Torn's cache naturally)
const POLL_EVENTS_MS = 300000; // events every 5 minutes (rehab/OD detection)
// Overdose bonus AP per OD event (only Xanax and Ecstasy generate extra AP)
const OD_AP = {
xanax: 100,
ecstasy: 20,
};
// Torn item image URLs (item ID confirmed via Torn API torn/?selections=items)
const XANAX_IMG_URL = 'https://www.torn.com/images/items/206/small.png';
// localStorage keys
const LS = {
API_KEY : 'tat-api-key',
FAC_MIT : 'tat-fac-mit', // Addiction XXV perk 0–50 (%)
BASELINE : 'tat-baseline', // JSON: drug totals at last rehab reset
REHAB_TS : 'tat-rehab-ts', // ms timestamp of last rehab reset
OD_XANAX : 'tat-od-xanax', // cumulative Xanax OD count since rehab
OD_ECSTASY : 'tat-od-ecstasy', // cumulative Ecstasy OD count since rehab
OD_LAST_TS : 'tat-od-last-ts', // ms timestamp of the most recent event we scanned
POS : 'tat-pos', // 0=BL 1=TL 2=BR 3=TR
SIZE : 'tat-size', // small | medium | large
};
// ─────────────────────────────────────────────────────────────────────────────
// REACTIVE STATE
// ─────────────────────────────────────────────────────────────────────────────
let S = {
drugCounts : {}, // { drugId: countSinceRehab }
odXanax : 0, // Xanax overdoses since last rehab
odEcstasy : 0, // Ecstasy overdoses since last rehab
odAP : 0, // total extra AP from overdoses
rawAP : 0, // sum of (count × AP) + odAP, before any reductions
mitigatedAP : 0, // rawAP × (1 − Addiction XXV%)
decayCount : 0, // number of 5TCT crossings since rehab
currentAP : 0, // mitigatedAP − (decayCount × 20), floored at 0
apAt18 : 0, // projected AP when CA is calculated at 18 TCT
caImpact : 0, // round(apAt18 / 8)
lastPoll : null, // Date.now() of last successful API call
apiError : null, // error string or null
expanded : false,
inSettings : false,
};
// ─────────────────────────────────────────────────────────────────────────────
// STORAGE HELPERS
// ─────────────────────────────────────────────────────────────────────────────
const lsGet = (k, d) => { const v = localStorage.getItem(k); return v === null ? d : v; };
const lsNum = (k, d) => { const v = parseFloat(lsGet(k, d)); return isNaN(v) ? d : v; };
const lsSet = (k, v) => localStorage.setItem(k, String(v));
function getBaseline() {
try { return JSON.parse(localStorage.getItem(LS.BASELINE)) || {}; }
catch { return {}; }
}
// Save current personalstats as the new rehab baseline.
// Also resets all OD counters — a new session starts clean.
function saveBaseline(stats) {
const b = {};
DRUGS.forEach(d => { b[d.id] = stats[d.field] || 0; });
lsSet(LS.BASELINE, JSON.stringify(b));
lsSet(LS.REHAB_TS, Date.now());
// Reset OD tracking — new cycle starts from now
lsSet(LS.OD_XANAX, 0);
lsSet(LS.OD_ECSTASY, 0);
lsSet(LS.OD_LAST_TS, Date.now());
console.log('[TornAddictionTracker] Baseline saved, OD counters reset.');
}
// ─────────────────────────────────────────────────────────────────────────────
// TIME UTILITIES (all UTC because TCT = UTC)
// ─────────────────────────────────────────────────────────────────────────────
// Returns how many times HH:00 UTC has ticked by since `sinceMs` up to now
function countHourCrossings(hourUTC, sinceMs) {
if (!sinceMs) return 0;
const now = Date.now();
if (sinceMs >= now) return 0;
const DAY = 86400000;
let t = new Date(sinceMs);
t.setUTCHours(hourUTC, 0, 0, 0);
if (t.getTime() <= sinceMs) t = new Date(t.getTime() + DAY);
let count = 0;
while (t.getTime() <= now) { count++; t = new Date(t.getTime() + DAY); }
return count;
}
// ms until the NEXT occurrence of HH:00 UTC from right now
function msUntil(hourUTC) {
const now = new Date();
let t = new Date();
t.setUTCHours(hourUTC, 0, 0, 0);
if (t <= now) t = new Date(t.getTime() + 86400000);
return t.getTime() - now.getTime();
}
// Format ms duration as "Xh Ym", "Xm Ys", or "Xs"
function fmtMs(ms) {
if (ms <= 0) return '0s';
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
// ─────────────────────────────────────────────────────────────────────────────
// CALCULATION ENGINE
// Called after every successful API poll with fresh personalstats.
// ─────────────────────────────────────────────────────────────────────────────
function recalculate(personalstats) {
const facMit = lsNum(LS.FAC_MIT, 0) / 100;
const baseline = getBaseline();
const rehabTs = parseInt(lsGet(LS.REHAB_TS, 0)) || 0;
// ── 1. Drug counts & raw AP ───────────────────────────────────────────────
// personalstats is a lifetime odometer; baseline is the reading at last rehab.
// delta = drugs taken since rehab.
const counts = {};
let raw = 0;
DRUGS.forEach(d => {
const lifetime = personalstats[d.field] || 0;
const atRehab = baseline[d.id] || 0;
const delta = Math.max(0, lifetime - atRehab);
counts[d.id] = delta;
raw += delta * d.ap;
});
// ── 2. Overdose AP ───────────────────────────────────────────────────────
// OD counts are maintained by poll() via event scanning.
// Each Xanax OD = +100 AP, each Ecstasy OD = +20 AP. Added to raw before
// mitigation and decay, since the game treats OD AP the same as drug AP.
const odXanax = lsNum(LS.OD_XANAX, 0);
const odEcstasy = lsNum(LS.OD_ECSTASY, 0);
const odAP = odXanax * OD_AP.xanax + odEcstasy * OD_AP.ecstasy;
raw += odAP;
// ── 3. Apply mitigation ───────────────────────────────────────────────────
// Addiction XXV perk reduces raw AP by up to 50%.
const mitigated = raw * (1 - facMit);
// ── 4. Daily 5TCT decays ──────────────────────────────────────────────────
// Flat −20 AP once per day at 05:00 UTC. Only counts crossings AFTER rehab.
const decays = countHourCrossings(DECAY_HOUR, rehabTs);
const current = Math.max(0, mitigated - decays * DECAY_AP);
// ── 5. Project AP at 18 TCT ───────────────────────────────────────────────
// AP at 18 TCT = current AP. The -20 decay only fires at exactly 5 TCT.
// countHourCrossings already only counts crossings that ACTUALLY happened
// after the rehab, so current AP is always correct in real time.
const apAt18 = current;
// ── 6. Company Addiction impact ───────────────────────────────────────────
// CA is always round(currentAP / 8) — it reflects your AP right now.
// apAt18 is kept as a separate projection for planning purposes only.
const caImpact = Math.round(current / AP_PER_CA);
// ── Commit to state ───────────────────────────────────────────────────────
S.drugCounts = counts;
S.odXanax = odXanax;
S.odEcstasy = odEcstasy;
S.odAP = odAP;
S.rawAP = raw;
S.mitigatedAP = mitigated;
S.decayCount = decays;
S.currentAP = current;
S.apAt18 = apAt18;
S.caImpact = caImpact;
}
// ─────────────────────────────────────────────────────────────────────────────
// OVERDOSE EVENT SCANNER
// Called each poll with the fresh events object from the API.
// Scans for new OD events AFTER the last scanned timestamp to avoid
// double-counting between polls. Updates localStorage OD counts incrementally.
// ─────────────────────────────────────────────────────────────────────────────
function scanODEvents(events, rehabTs) {
if (!events) return;
// Scan from whichever is later: last rehab or last scan checkpoint.
// This prevents counting ODs that happened before the current rehab cycle.
const lastScan = parseInt(lsGet(LS.OD_LAST_TS, 0)) || 0;
const scanFrom = Math.max(rehabTs, lastScan);
let newOdXanax = 0;
let newOdEcstasy = 0;
let maxEventTs = scanFrom; // advance the checkpoint even on non-OD events
Object.values(events).forEach(e => {
const eTs = e.timestamp * 1000;
if (eTs <= scanFrom) return; // already seen this event
maxEventTs = Math.max(maxEventTs, eTs);
if (typeof e.event !== 'string' || !/overdos/i.test(e.event)) return;
if (/xanax/i.test(e.event)) newOdXanax++;
else if (/ecstasy/i.test(e.event)) newOdEcstasy++;
});
// Persist checkpoint and new counts
lsSet(LS.OD_LAST_TS, maxEventTs);
if (newOdXanax) lsSet(LS.OD_XANAX, lsNum(LS.OD_XANAX, 0) + newOdXanax);
if (newOdEcstasy) lsSet(LS.OD_ECSTASY, lsNum(LS.OD_ECSTASY, 0) + newOdEcstasy);
if (newOdXanax || newOdEcstasy) {
console.log(`[TornAddictionTracker] New ODs: Xanax×${newOdXanax} Ecstasy×${newOdEcstasy}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// API POLLING
// Split into two rates:
// pollStats() — personalstats only, every 30s. Uses Torn's 30s cache
// naturally (no timestamp bypass) so back-to-back calls
// within the cache window are free (don't count against quota).
// pollEvents() — events only, every 5 minutes. Events are the expensive
// cloud selection. Only needed for rehab/OD detection which
// are rare events, not second-by-second.
// ─────────────────────────────────────────────────────────────────────────────
async function pollStats() {
const key = lsGet(LS.API_KEY, '');
if (!key) {
S.apiError = '⚠ No API key — open Settings';
renderAll();
return;
}
try {
// No ×tamp here — we let the 30s cache work.
// Two polls within 30s return the same cached data for free.
const resp = await fetch(
`https://api.torn.com/user/?selections=personalstats&key=${key}&comment=TornAddictionTracker`
);
const data = await resp.json();
if (data.error) {
S.apiError = `API error: ${data.error.error}`;
renderAll();
return;
}
S.apiError = null;
S.lastPoll = Date.now();
recalculate(data.personalstats);
renderAll();
} catch (err) {
S.apiError = 'Network error';
renderAll();
}
}
async function pollEvents() {
const key = lsGet(LS.API_KEY, '');
if (!key) return;
try {
const resp = await fetch(
`https://api.torn.com/user/?selections=personalstats,events&key=${key}&comment=TornAddictionTracker`
);
const data = await resp.json();
if (data.error) return;
// ── Rehab detection ───────────────────────────────────────────────────
const rehabTs = parseInt(lsGet(LS.REHAB_TS, 0)) || 0;
const hasBaseline = Object.keys(getBaseline()).length > 0;
if (data.events) {
const events = Object.values(data.events);
const rehabEvent = events.find(e =>
e.timestamp * 1000 > rehabTs &&
typeof e.event === 'string' &&
/rehab/i.test(e.event)
);
if (rehabEvent || !hasBaseline) {
saveBaseline(data.personalstats);
}
} else if (!hasBaseline) {
saveBaseline(data.personalstats);
}
// ── OD event scanning ─────────────────────────────────────────────────
const currentRehabTs = parseInt(lsGet(LS.REHAB_TS, 0)) || 0;
scanODEvents(data.events, currentRehabTs);
recalculate(data.personalstats);
renderAll();
} catch (err) { /* silent — pollStats will surface errors */ }
}
// Convenience: full poll (stats + events), used on focus/navigation events
async function poll() {
await pollEvents(); // includes personalstats too
}
// ─────────────────────────────────────────────────────────────────────────────
// DOM HELPERS
// ─────────────────────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const tx = (id, v) => { const e = $(id); if (e) e.textContent = v; };
// ─────────────────────────────────────────────────────────────────────────────
// RENDER
// ─────────────────────────────────────────────────────────────────────────────
function renderAll() {
renderButton();
if (S.expanded && !S.inSettings) renderPanel();
}
function renderButton() {
// Button shows: [xanax icon] [xanax count] | AP: [total current AP]
tx('tat-btn-xanax', `${S.drugCounts?.xanax ?? 0}`);
tx('tat-btn-ap', `AP: ${Math.round(S.currentAP)}`);
const errDot = $('tat-btn-err');
if (errDot) errDot.style.display = S.apiError ? 'inline' : 'none';
}
function renderPanel() {
// ── Drug table ────────────────────────────────────────────────────────────
const tbody = $('tat-tbody');
if (tbody) {
let html = '';
let totCount = 0, totAP = 0;
DRUGS.forEach(d => {
const n = S.drugCounts[d.id] || 0;
if (n > 0) {
const ap = n * d.ap;
totCount += n;
totAP += ap;
html += `<tr>
<td>${d.label}</td>
<td class="tat-num">${n}</td>
<td class="tat-num">${ap}</td>
</tr>`;
}
// Add OD sub-row beneath Xanax and Ecstasy if any ODs occurred
const odCount = d.id === 'xanax' ? S.odXanax
: d.id === 'ecstasy' ? S.odEcstasy
: 0;
if (odCount > 0) {
const odApVal = odCount * OD_AP[d.id];
totAP += odApVal;
html += `<tr class="tat-od-row">
<td class="tat-od-label">↳ OD ×${odCount}</td>
<td></td>
<td class="tat-num tat-od-ap">+${odApVal}</td>
</tr>`;
}
});
// Add decay rows — one row per 5 TCT that has fired since rehab
if (S.decayCount > 0) {
html += `<tr class="tat-decay-sep"><td colspan="3"></td></tr>`;
for (let i = 0; i < S.decayCount; i++) {
html += `<tr class="tat-decay-row">
<td class="tat-decay-label">3 TCT Decay</td>
<td></td>
<td class="tat-num tat-decay-ap">−${DECAY_AP}</td>
</tr>`;
}
totAP = Math.max(0, totAP - S.decayCount * DECAY_AP);
}
tbody.innerHTML = html ||
`<tr><td colspan="3" class="tat-none">No drugs recorded yet</td></tr>`;
tx('tat-tot-count', totCount);
tx('tat-tot-ap', totAP);
}
// ── AP summary ────────────────────────────────────────────────────────────
const facMit = lsNum(LS.FAC_MIT, 0);
tx('tat-raw-ap', Math.round(S.rawAP));
tx('tat-mit-ap', `${Math.round(S.mitigatedAP)} (−${facMit}%)`);
tx('tat-cur-ap', Math.round(S.currentAP));
// ── Stale baseline warning ────────────────────────────────────────────────
// If decays have fully zeroed out AP despite drugs being present,
// the REHAB_TS is likely stale (e.g. set on first install, not after a real rehab).
const warnEl = $('tat-stale-warn');
if (warnEl) {
const stale = S.rawAP > 0 && S.currentAP === 0 && S.decayCount > 0;
warnEl.style.display = stale ? 'block' : 'none';
}
// ── CA ────────────────────────────────────────────────────────────────────
tx('tat-ca-imp', `−${S.caImpact}`);
// ── Countdown timers ──────────────────────────────────────────────────────
tx('tat-18-val', fmtMs(msUntil(CA_HOUR)));
tx('tat-5-val', fmtMs(msUntil(DECAY_HOUR)));
// ── Status bar ────────────────────────────────────────────────────────────
if (S.lastPoll) {
tx('tat-poll-ago', `Updated ${Math.round((Date.now() - S.lastPoll) / 1000)}s ago`);
} else {
tx('tat-poll-ago', 'Polling...');
}
tx('tat-err-msg', S.apiError || '');
}
// ─────────────────────────────────────────────────────────────────────────────
// HTML TEMPLATE
// ─────────────────────────────────────────────────────────────────────────────
function buildHTML() {
return `
<div id="tat-button">
<img src="${XANAX_IMG_URL}" class="tat-xanax-icon" alt="Xanax" title="Xanax">
<span id="tat-btn-xanax">0</span>
<span class="tat-sep">|</span>
<span id="tat-btn-ap">AP: 0</span>
<span id="tat-btn-err" class="tat-err-dot" style="display:none" title="Error — open settings">⚠</span>
</div>
<div id="tat-panel" style="display:none">
<!-- ── Header ─────────────────────────────────────────── -->
<div class="tat-header">
<span><img src="${XANAX_IMG_URL}" class="tat-xanax-icon-sm" alt=""> Drug Tracker</span>
<span class="tat-hdr-btns">
<button id="tat-btn-settings" title="Settings">⚙</button>
<button id="tat-btn-close" title="Close">✕</button>
</span>
</div>
<!-- ── Main view ──────────────────────────────────────── -->
<div id="tat-view-main">
<div class="tat-block">
<div class="tat-block-title">Drugs Since Last Rehab</div>
<table class="tat-table">
<thead>
<tr><th>Drug</th><th class="tat-num">Count</th><th class="tat-num">AP</th></tr>
</thead>
<tbody id="tat-tbody"></tbody>
<tfoot>
<tr>
<td><strong>Total (raw)</strong></td>
<td class="tat-num" id="tat-tot-count">0</td>
<td class="tat-num" id="tat-tot-ap">0</td>
</tr>
</tfoot>
</table>
</div>
<div class="tat-block">
<div class="tat-block-title">Addiction Points (AP)</div>
<div class="tat-row">
<span>Raw AP</span>
<span id="tat-raw-ap">0</span>
</div>
<div class="tat-row">
<span>After Mitigation</span>
<span id="tat-mit-ap">0</span>
</div>
<div class="tat-row tat-accent">
<span>Current AP</span>
<span id="tat-cur-ap">0</span>
</div>
<div id="tat-stale-warn" class="tat-warn" style="display:none">
⚠ Daily decays have fully zeroed your AP.<br>
Your baseline may be stale. Use <strong>Manual Rehab Reset</strong> in Settings if you recently rehabbed and it wasn't detected.
</div>
</div>
<div class="tat-block">
<div class="tat-block-title">Company Addiction</div>
<div class="tat-row tat-accent">
<span>CA Impact</span>
<span id="tat-ca-imp">−0</span>
</div>
<div class="tat-row tat-dim">
<span>Time to 18 TCT</span>
<span id="tat-18-val">—</span>
</div>
<div class="tat-row tat-dim">
<span>Time to 3 TCT (decay)</span>
<span id="tat-5-val">—</span>
</div>
</div>
<div class="tat-status">
<span id="tat-poll-ago">—</span>
<span id="tat-err-msg" class="tat-err-txt"></span>
</div>
</div><!-- /tat-view-main -->
<!-- ── Settings view ──────────────────────────────────── -->
<div id="tat-view-settings" style="display:none">
<div class="tat-block">
<div class="tat-block-title">Settings</div>
<div class="tat-field">
<label>Torn API Key</label>
<input id="tat-s-key" type="password" placeholder="Paste your API key here" autocomplete="off" spellcheck="false">
<small>
Torn → Settings → API → Add new key<br>
⚠ Requires <strong>Limited Access</strong> (not Minimal or Public).<br>
Use a <strong>dedicated key</strong> for this script — not your Full Access key.<br>
Selections needed: <code>user → personalstats, events</code>
</small>
</div>
<div class="tat-field">
<label>Faction Perk — "Addiction XXV" <span class="tat-muted">(0–50%, steps of 2%)</span></label>
<div class="tat-range">
<input id="tat-s-fac" type="range" min="0" max="50" step="2" value="0">
<span id="tat-s-fac-val">0%</span>
</div>
</div>
<div class="tat-field">
<label>Float Position</label>
<select id="tat-s-pos">
<option value="0">Bottom Left</option>
<option value="1">Top Left</option>
<option value="2">Bottom Right</option>
<option value="3">Top Right</option>
</select>
</div>
<div class="tat-field">
<label>Button Size</label>
<select id="tat-s-size">
<option value="small">Small</option>
<option value="medium" selected>Medium</option>
<option value="large">Large</option>
</select>
</div>
<div class="tat-field">
<button id="tat-s-rehab" class="tat-btn-danger">⟳ Manual Rehab Reset</button>
<small>Use this if you rehabbed but the script did not auto-detect it.</small>
</div>
</div>
<div class="tat-settings-footer">
<button id="tat-s-save" class="tat-btn-save">Save</button>
<button id="tat-s-cancel" class="tat-btn-cancel">Cancel</button>
</div>
</div><!-- /tat-view-settings -->
</div><!-- /tat-panel -->`;
}
// ─────────────────────────────────────────────────────────────────────────────
// VIEW SWITCHING
// ─────────────────────────────────────────────────────────────────────────────
function showMain() {
S.inSettings = false;
$('tat-view-main').style.display = 'block';
$('tat-view-settings').style.display = 'none';
}
function showSettings() {
S.inSettings = true;
$('tat-view-main').style.display = 'none';
$('tat-view-settings').style.display = 'block';
$('tat-s-key').value = lsGet(LS.API_KEY, '');
const fac = lsNum(LS.FAC_MIT, 0);
$('tat-s-fac').value = fac; tx('tat-s-fac-val', `${fac}%`);
$('tat-s-pos').value = lsGet(LS.POS, '0');
$('tat-s-size').value = lsGet(LS.SIZE, 'medium');
}
// ─────────────────────────────────────────────────────────────────────────────
// POSITION & SIZE
// ─────────────────────────────────────────────────────────────────────────────
function applyPosition() {
const wrap = $('tat-wrap');
wrap.classList.remove('pos-bl', 'pos-tl', 'pos-br', 'pos-tr');
wrap.classList.add(['pos-bl','pos-tl','pos-br','pos-tr'][parseInt(lsGet(LS.POS, 0))] || 'pos-bl');
}
function applySize() {
const btn = $('tat-button');
btn.classList.remove('sz-small', 'sz-medium', 'sz-large');
btn.classList.add(`sz-${lsGet(LS.SIZE, 'medium')}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// EVENT WIRING
// ─────────────────────────────────────────────────────────────────────────────
function attachEvents() {
$('tat-button').addEventListener('click', () => {
S.expanded = !S.expanded;
$('tat-panel').style.display = S.expanded ? 'flex' : 'none';
if (S.expanded) { showMain(); renderPanel(); }
});
$('tat-btn-close').addEventListener('click', e => {
e.stopPropagation();
S.expanded = false;
$('tat-panel').style.display = 'none';
});
$('tat-btn-settings').addEventListener('click', e => {
e.stopPropagation();
showSettings();
});
const slider = $('tat-s-fac');
const label = $('tat-s-fac-val');
slider.addEventListener('input', () => { label.textContent = `${slider.value}%`; });
$('tat-s-save').addEventListener('click', () => {
lsSet(LS.API_KEY, $('tat-s-key').value.trim());
lsSet(LS.FAC_MIT, $('tat-s-fac').value);
lsSet(LS.POS, $('tat-s-pos').value);
lsSet(LS.SIZE, $('tat-s-size').value);
applyPosition();
applySize();
showMain();
poll();
});
$('tat-s-cancel').addEventListener('click', () => showMain());
$('tat-s-rehab').addEventListener('click', () => {
if (confirm(
'Reset the drug tracker?\n\n' +
'This sets your CURRENT drug totals as the new baseline, ' +
'as if you just walked out of rehab. OD counters will also reset.\n\n' +
'Only do this if the script missed your rehab.'
)) {
localStorage.removeItem(LS.BASELINE);
localStorage.removeItem(LS.REHAB_TS);
localStorage.removeItem(LS.OD_XANAX);
localStorage.removeItem(LS.OD_ECSTASY);
localStorage.removeItem(LS.OD_LAST_TS);
showMain();
poll();
}
});
}
// ─────────────────────────────────────────────────────────────────────────────
// INSERT UI
// ─────────────────────────────────────────────────────────────────────────────
function insertUI() {
if ($('tat-wrap')) return;
const wrap = document.createElement('div');
wrap.id = 'tat-wrap';
wrap.innerHTML = buildHTML();
document.body.appendChild(wrap);
applyPosition();
applySize();
attachEvents();
insertCSS();
}
// ─────────────────────────────────────────────────────────────────────────────
// CSS
// ─────────────────────────────────────────────────────────────────────────────
function insertCSS() {
GM.addStyle(`
/* ── Wrapper ────────────────────────────────────────────── */
#tat-wrap {
position: fixed;
z-index: 999999;
font-family: Arial, sans-serif;
font-size: 13px;
}
#tat-wrap.pos-bl { bottom: 80px; left: 10px; }
#tat-wrap.pos-tl { top: 80px; left: 10px; }
#tat-wrap.pos-br { bottom: 80px; right: 10px; }
#tat-wrap.pos-tr { top: 80px; right: 10px; }
/* ── Panel ──────────────────────────────────────────────── */
#tat-panel {
position: absolute;
width: 295px;
flex-direction: column;
background: var(--default-bg-panel-color, #1c1c1c);
border: 1px solid var(--default-panel-divider-outer-side-color, #444);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0,0,0,.5);
color: var(--default-color, #e0e0e0);
max-height: 82vh;
overflow-y: auto;
}
#tat-wrap.pos-bl #tat-panel,
#tat-wrap.pos-br #tat-panel { bottom: calc(100% + 6px); }
#tat-wrap.pos-tl #tat-panel,
#tat-wrap.pos-tr #tat-panel { top: calc(100% + 6px); }
#tat-wrap.pos-bl #tat-panel,
#tat-wrap.pos-tl #tat-panel { left: 0; }
#tat-wrap.pos-br #tat-panel,
#tat-wrap.pos-tr #tat-panel { right: 0; }
/* ── Button ─────────────────────────────────────────────── */
#tat-button {
display: flex;
align-items: center;
gap: 6px;
border-radius: 8px;
padding: 5px 12px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
user-select: none;
color: var(--default-color, #eee);
background: var(--info-msg-bg-gradient, linear-gradient(180deg,#2e2e2e,#1a1a1a));
border: 1px solid var(--default-panel-divider-outer-side-color, #444);
box-shadow: 0 2px 8px rgba(0,0,0,.4);
transition: filter 0.1s;
}
#tat-button:hover { filter: brightness(1.18); }
#tat-button .tat-sep { opacity: 0.35; }
.tat-err-dot { color: #e05555; font-size: 12px; }
/* Xanax icon sizing */
.tat-xanax-icon {
width: 22px;
height: 22px;
object-fit: contain;
image-rendering: pixelated;
flex-shrink: 0;
}
.tat-xanax-icon-sm {
width: 14px;
height: 14px;
object-fit: contain;
image-rendering: pixelated;
vertical-align: middle;
margin-right: 2px;
}
#tat-button.sz-small { font-size: 11px; padding: 3px 9px; }
#tat-button.sz-small .tat-xanax-icon { width: 16px; height: 16px; }
#tat-button.sz-medium { font-size: 13px; padding: 5px 12px; }
#tat-button.sz-large { font-size: 16px; padding: 8px 16px; }
#tat-button.sz-large .tat-xanax-icon { width: 28px; height: 28px; }
/* ── Panel header ───────────────────────────────────────── */
.tat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
font-weight: 700;
font-size: 13px;
background: var(--default-bg-panel-active-color, #242424);
border-bottom: 1px solid var(--default-panel-divider-inner-color, #333);
border-radius: 8px 8px 0 0;
flex-shrink: 0;
}
.tat-hdr-btns { display: flex; gap: 2px; }
.tat-hdr-btns button {
background: transparent;
border: none;
color: var(--default-color, #ccc);
cursor: pointer;
padding: 2px 7px;
border-radius: 4px;
font-size: 15px;
line-height: 1;
}
.tat-hdr-btns button:hover { background: rgba(255,255,255,.08); }
/* ── Content blocks ─────────────────────────────────────── */
.tat-block {
padding: 8px 10px;
border-bottom: 1px solid var(--default-panel-divider-inner-color, #2a2a2a);
}
.tat-block:last-child { border-bottom: none; }
.tat-block-title {
font-size: 9px;
letter-spacing: 1.3px;
text-transform: uppercase;
color: var(--default-full-text-color, #666);
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid var(--default-panel-divider-inner-color, #2a2a2a);
}
/* ── Data rows ──────────────────────────────────────────── */
.tat-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
font-size: 12px;
}
.tat-row.tat-accent { font-weight: 700; color: #afc372; margin-top: 3px; }
.tat-row.tat-dim { font-size: 11px; color: var(--default-full-text-color, #777); }
/* ── Drug table ─────────────────────────────────────────── */
.tat-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.tat-table th {
text-align: left;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--default-full-text-color, #555);
padding: 0 4px 4px;
}
.tat-table th.tat-num,
.tat-table td.tat-num { text-align: right; }
.tat-table td { padding: 2px 4px; }
.tat-table tfoot td {
font-weight: 700;
border-top: 1px solid var(--default-panel-divider-inner-color, #333);
padding-top: 4px;
}
.tat-none {
text-align: center;
font-style: italic;
color: var(--default-full-text-color, #555);
padding: 6px 0 !important;
}
/* Stale baseline warning */
.tat-warn {
margin-top: 6px;
padding: 6px 8px;
border-radius: 4px;
background: rgba(224,85,85,.12);
border: 1px solid #e05555;
color: #e05555;
font-size: 10px;
line-height: 1.5;
}
/* Decay rows in drug table */
.tat-decay-sep td {
border-top: 1px solid var(--default-panel-divider-inner-color, #333);
padding: 2px 0 0 !important;
}
.tat-decay-label {
font-size: 11px;
color: #e05555;
font-style: italic;
padding-left: 4px !important;
}
.tat-decay-ap {
color: #e05555;
font-weight: 700;
font-size: 11px;
}
/* OD sub-rows: indented, orange accent */
.tat-od-row { }
.tat-od-label {
font-size: 11px;
padding-left: 12px !important;
color: #d4863a;
font-style: italic;
}
.tat-od-ap {
color: #d4863a;
font-size: 11px;
font-weight: 700;
}
/* ── Status bar ─────────────────────────────────────────── */
.tat-status {
display: flex;
justify-content: space-between;
padding: 4px 10px 6px;
font-size: 10px;
color: var(--default-full-text-color, #555);
background: var(--default-bg-panel-active-color, #1a1a1a);
border-radius: 0 0 8px 8px;
}
.tat-err-txt { color: #e05555; }
/* ── Settings ───────────────────────────────────────────── */
#tat-view-settings { padding: 0; }
.tat-field { margin-bottom: 11px; }
.tat-field label {
display: block;
font-size: 11px;
font-weight: 600;
margin-bottom: 4px;
color: var(--default-full-text-color, #aaa);
letter-spacing: 0.2px;
}
.tat-muted {
font-weight: 400;
font-size: 10px;
color: var(--default-full-text-color, #666);
}
.tat-field small {
display: block;
font-size: 10px;
color: var(--default-full-text-color, #555);
margin-top: 3px;
}
.tat-field input[type="password"],
.tat-field select {
width: 100%;
box-sizing: border-box;
background: var(--default-bg-panel-color, #111);
border: 1px solid var(--default-panel-divider-outer-side-color, #444);
border-radius: 4px;
color: var(--default-color, #eee);
padding: 5px 8px;
font-size: 12px;
font-family: Arial, sans-serif;
}
.tat-range {
display: flex;
align-items: center;
gap: 8px;
}
.tat-range input[type="range"] {
flex: 1;
accent-color: #afc372;
cursor: pointer;
}
.tat-range span {
font-size: 12px;
font-weight: 700;
color: #afc372;
min-width: 34px;
text-align: right;
}
.tat-btn-danger {
width: 100%;
background: transparent;
color: var(--user-status-red-color, #e05555);
border: 1px solid var(--user-status-red-color, #e05555);
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
font-family: Arial, sans-serif;
}
.tat-btn-danger:hover { background: rgba(224,85,85,.1); }
.tat-settings-footer {
display: flex;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--default-panel-divider-inner-color, #333);
background: var(--default-bg-panel-active-color, #1a1a1a);
border-radius: 0 0 8px 8px;
}
.tat-btn-save {
background: #afc372;
color: #1a1a1a;
border: none;
border-radius: 4px;
padding: 6px 20px;
font-weight: 700;
cursor: pointer;
font-size: 12px;
font-family: Arial, sans-serif;
}
.tat-btn-save:hover { background: #c5da88; }
.tat-btn-cancel {
background: transparent;
color: var(--default-color, #eee);
border: 1px solid var(--default-panel-divider-outer-side-color, #555);
border-radius: 4px;
padding: 6px 14px;
cursor: pointer;
font-size: 12px;
font-family: Arial, sans-serif;
}
.tat-btn-cancel:hover { background: rgba(255,255,255,.06); }
`);
}
// ─────────────────────────────────────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────────────────────────────────────
function init() {
if (!document.body) {
const obs = new MutationObserver(() => {
if (document.body) { obs.disconnect(); init(); }
});
obs.observe(document.documentElement, { childList: true });
return;
}
insertUI();
// Initial load: full poll (stats + events) to set baseline and OD state
poll();
// Frequent: personalstats only — drug counts, AP, CA
setInterval(pollStats, POLL_MS);
// Infrequent: events — rehab detection, OD detection
setInterval(pollEvents, POLL_EVENTS_MS);
// On focus/navigation: full poll so data is always fresh when returning
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') poll();
});
window.addEventListener('pageshow', () => poll());
window.addEventListener('hashchange', () => pollStats()); // navigation = stats only
setInterval(() => {
renderButton();
if (S.expanded && !S.inSettings) {
tx('tat-18-val', fmtMs(msUntil(CA_HOUR)));
tx('tat-5-val', fmtMs(msUntil(DECAY_HOUR)));
if (S.lastPoll) {
tx('tat-poll-ago', `Updated ${Math.round((Date.now() - S.lastPoll) / 1000)}s ago`);
}
}
}, 1000);
}
init();