diff --git a/docs/superpowers/plans/2026-06-01-torn-attribute-training-tracker.md b/docs/superpowers/plans/2026-06-01-torn-attribute-training-tracker.md new file mode 100644 index 0000000..6187f9e --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-torn-attribute-training-tracker.md @@ -0,0 +1,2139 @@ +# Torn Attribute Training Tracker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Tampermonkey-compatible userscript that shows a floating dialog on Torn's gym page, tracking attribute value, target, per-train/per-day increase, and ETA to target. + +**Architecture:** Single userscript file (`torn-attribute-tracker.user.js`) with three in-file modules — `DataLayer` (DOM + network), `Store` (localStorage + history), `UI` (dialog + drag/anchor) — wired by a small `main` block. Pure functions are exported on `window.TAT` and exercised by both an in-browser `#tat-test` self-test and a Node-runnable test file. + +**Tech Stack:** Vanilla JavaScript (ES2017+), Tampermonkey/Greasemonkey/Violentmonkey, localStorage, Node.js (for tests only). + +--- + +## File Structure + +Files this plan creates: + +- `package.json` — minimal Node setup for the test runner (`type: "module"`, `npm test` script). +- `torn-attribute-tracker.user.js` — the deliverable userscript (IIFE bundle). +- `src/pure.js` — pure functions (`parseTarget`, `computeEstimate`, `pruneHistory`, `summary`). Imported by tests, also embedded in the userscript via an IIFE-wrapped copy in Task 1. +- `src/dom.js` — `currentAttribute()` (gym page selectors) and the anchor target lookup. Browser-only; not testable in Node (so we test only the parts that *are* testable and verify the rest by hand in the manual test task). +- `src/interceptor.js` — `startRequestInterceptor()`. Browser-only; manually verified. +- `src/store.js` — `Store` class. Imported by tests (Node shim for `localStorage`) and embedded in the userscript. +- `src/ui.js` — `Dialog` class. Browser-only; manually verified. +- `src/main.js` — wiring. Browser-only. +- `tests/pure.test.js` — Node test runner for the pure functions. +- `tests/store.test.js` — Node test runner for the Store (using a `localStorage` shim). +- `tests/build.test.js` — verifies the embedded copy of `pure.js` inside the userscript matches the source file (catches drift between tests and the shipped script). +- `README.md` — install + usage. +- `docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md` — already exists, untouched. + +Tests run with `npm test` (Node). The `#tat-test` self-test in the browser is described in the README and is verified by the final manual task. + +--- + +## Task 0: Project skeleton + +**Files:** +- Create: `package.json` +- Create: `.gitignore` +- Create: `README.md` (placeholder) + +- [ ] **Step 1: Initialize git** + +```bash +cd "C:/Users/ksolo/Projects/Games/Torn Training Tracker" +git init +git config user.email "dev@local" +git config user.name "dev" +``` + +- [ ] **Step 2: Create `package.json`** + +```json +{ + "name": "torn-attribute-training-tracker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/" + } +} +``` + +- [ ] **Step 3: Create `.gitignore`** + +``` +node_modules/ +.superpowers/ +.vscode/ +``` + +- [ ] **Step 4: Create placeholder `README.md`** + +```markdown +# Torn Attribute Training Tracker + +A userscript for [torn.com](https://www.torn.com) that shows a floating dialog on the gym page with your attribute, target, and ETA. + +_(installation and usage coming once tasks complete)_ +``` + +- [ ] **Step 5: Commit** + +```bash +git add package.json .gitignore README.md +git commit -m "chore: initialize project" +``` + +--- + +## Task 1: Pure target parser + +**Files:** +- Create: `src/pure.js` +- Create: `tests/pure.test.js` + +Pure function `parseTarget(input)` that converts user-typed strings to a positive integer, or returns `null` for invalid input. Spec: + +- Accepts numbers: `25`, `25000000`, `2.5e7` → return as integer. +- Accepts strings: `"25"`, `"25,000,000"`, `"25,000,000.5"` → strip commas, parse, floor. +- Accepts magnitude suffixes: `"25M"`, `"1.5B"`, `"100K"`, `"2T"` (case-insensitive). +- Rejects: zero, negative, non-numeric, fractional after expansion (e.g. `"1.5M"` → `1_500_000`, ok; `"1.5M.5"` → null). + +- [ ] **Step 1: Write the failing test** + +`tests/pure.test.js`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseTarget } from '../src/pure.js'; + +test('parseTarget: integer numbers', () => { + assert.equal(parseTarget(25), 25); + assert.equal(parseTarget(25000000), 25_000_000); + assert.equal(parseTarget(2.5e7), 25_000_000); +}); + +test('parseTarget: string numbers with commas', () => { + assert.equal(parseTarget('25'), 25); + assert.equal(parseTarget('25,000,000'), 25_000_000); + assert.equal(parseTarget('25,000,000.5'), 25_000_000); +}); + +test('parseTarget: magnitude suffixes (case-insensitive)', () => { + assert.equal(parseTarget('25K'), 25_000); + assert.equal(parseTarget('25m'), 25_000_000); + assert.equal(parseTarget('1.5B'), 1_500_000_000); + assert.equal(parseTarget('100t'), 100_000_000_000_000); +}); + +test('parseTarget: rejects invalid input', () => { + assert.equal(parseTarget(0), null); + assert.equal(parseTarget(-1), null); + assert.equal(parseTarget('abc'), null); + assert.equal(parseTarget(''), null); + assert.equal(parseTarget(null), null); + assert.equal(parseTarget(undefined), null); + assert.equal(parseTarget('25M.5'), null); + assert.equal(parseTarget('1.5.5M'), null); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test` +Expected: FAIL — `Cannot find module '../src/pure.js'` or `parseTarget is not a function`. + +- [ ] **Step 3: Implement `parseTarget`** + +`src/pure.js`: + +```js +const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }; + +export function parseTarget(input) { + if (input === null || input === undefined || input === '') return null; + + if (typeof input === 'number') { + if (!Number.isFinite(input) || input <= 0) return null; + return Math.floor(input); + } + + if (typeof input !== 'string') return null; + + const cleaned = input.replace(/,/g, '').trim().toLowerCase(); + if (cleaned === '') return null; + + // Reject anything other than digits, optional decimal, and optional single trailing suffix letter + if (!/^\d+(\.\d+)?[kmbt]?$/.test(cleaned)) return null; + + const match = cleaned.match(/^(\d+(?:\.\d+)?)([kmbt])?$/); + if (!match) return null; + + const num = parseFloat(match[1]); + if (!Number.isFinite(num) || num <= 0) return null; + + const suffix = match[2]; + const multiplier = suffix ? SUFFIXES[suffix] : 1; + return Math.floor(num * multiplier); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — all 4 `parseTarget` test cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/pure.js tests/pure.test.js +git commit -m "feat(pure): parseTarget with comma and magnitude suffix support" +``` + +--- + +## Task 2: Pure `computeEstimate` + +**Files:** +- Modify: `src/pure.js` +- Modify: `tests/pure.test.js` + +Pure function that returns `{ remaining, trainsToGo, days, eta }` from `(current, target, perTrain, perDay)`. Spec math: + +- `remaining = target − current` (clamp to 0 if target reached). +- `trainsToGo = ceil(remaining / perTrain)` (0 if perTrain ≤ 0 or remaining ≤ 0). +- `days = ceil(remaining / perDay)` (0 if perDay ≤ 0 or remaining ≤ 0). +- `eta` = a `Date` for `today + days` (or `null` if days is 0). + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pure.test.js`: + +```js +import { computeEstimate } from '../src/pure.js'; + +test('computeEstimate: typical case', () => { + const r = computeEstimate(14_328_501, 25_000_000, 247, 4520); + assert.equal(r.remaining, 10_671_499); + assert.equal(r.trainsToGo, 43_205); + assert.equal(r.days, 2_362); + assert.ok(r.eta instanceof Date); +}); + +test('computeEstimate: target reached', () => { + const r = computeEstimate(25_000_000, 25_000_000, 247, 4520); + assert.equal(r.remaining, 0); + assert.equal(r.trainsToGo, 0); + assert.equal(r.days, 0); + assert.equal(r.eta, null); +}); + +test('computeEstimate: target below current', () => { + const r = computeEstimate(30_000_000, 25_000_000, 247, 4520); + assert.equal(r.remaining, 0); + assert.equal(r.trainsToGo, 0); + assert.equal(r.days, 0); + assert.equal(r.eta, null); +}); + +test('computeEstimate: zero perTrain or perDay does not crash', () => { + const a = computeEstimate(100, 200, 0, 50); + assert.equal(a.trainsToGo, 0); + assert.equal(a.days, 2); + + const b = computeEstimate(100, 200, 50, 0); + assert.equal(b.trainsToGo, 2); + assert.equal(b.days, 0); + assert.equal(b.eta, null); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test` +Expected: FAIL — `computeEstimate is not a function`. + +- [ ] **Step 3: Implement `computeEstimate`** + +Append to `src/pure.js`: + +```js +export function computeEstimate(current, target, perTrain, perDay) { + const remaining = Math.max(0, target - current); + if (remaining === 0) { + return { remaining: 0, trainsToGo: 0, days: 0, eta: null }; + } + + const trainsToGo = perTrain > 0 ? Math.ceil(remaining / perTrain) : 0; + const days = perDay > 0 ? Math.ceil(remaining / perDay) : 0; + const eta = days > 0 ? new Date(Date.now() + days * 86_400_000) : null; + + return { remaining, trainsToGo, days, eta }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — all `computeEstimate` test cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/pure.js tests/pure.test.js +git commit -m "feat(pure): computeEstimate with safe division and target-reached handling" +``` + +--- + +## Task 3: Pure `pruneHistory` + +**Files:** +- Modify: `src/pure.js` +- Modify: `tests/pure.test.js` + +Pure function `pruneHistory(entries, now = Date.now())` that returns a new array of entries with `ts >= now − 30 * 86_400_000` (i.e. last 30 days). Does not mutate input. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pure.test.js`: + +```js +import { pruneHistory } from '../src/pure.js'; + +const DAY = 86_400_000; +const NOW = 1_700_000_000_000; // fixed reference + +test('pruneHistory: keeps entries within 30 days, drops older', () => { + const entries = [ + { ts: NOW, delta: 100 }, + { ts: NOW - 1 * DAY, delta: 100 }, + { ts: NOW - 29 * DAY, delta: 100 }, + { ts: NOW - 30 * DAY, delta: 100 }, // exactly 30 days, dropped (strict <) + { ts: NOW - 31 * DAY, delta: 100 }, + ]; + const out = pruneHistory(entries, NOW); + assert.equal(out.length, 3); + assert.deepEqual(out.map((e) => e.ts), [NOW, NOW - 1 * DAY, NOW - 29 * DAY]); +}); + +test('pruneHistory: does not mutate input', () => { + const entries = [{ ts: NOW - 31 * DAY, delta: 100 }]; + const copy = [...entries]; + pruneHistory(entries, NOW); + assert.deepEqual(entries, copy); +}); + +test('pruneHistory: empty input', () => { + assert.deepEqual(pruneHistory([], NOW), []); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test` +Expected: FAIL — `pruneHistory is not a function`. + +- [ ] **Step 3: Implement `pruneHistory`** + +Append to `src/pure.js`: + +```js +const THIRTY_DAYS_MS = 30 * 86_400_000; + +export function pruneHistory(entries, now = Date.now()) { + const cutoff = now - THIRTY_DAYS_MS; + return entries.filter((e) => e.ts >= cutoff); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — all 3 `pruneHistory` test cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/pure.js tests/pure.test.js +git commit -m "feat(pure): pruneHistory with strict 30-day window" +``` + +--- + +## Task 4: Pure `summary` + +**Files:** +- Modify: `src/pure.js` +- Modify: `tests/pure.test.js` + +Pure function `summary(entries, now = Date.now())` returning `{ trainsToday, sevenDayAvgPerDay, perDay }` per the spec. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pure.test.js`: + +```js +import { summary } from '../src/pure.js'; + +const DAY = 86_400_000; +const NOW = 1_700_000_000_000; + +test('summary: empty history returns zeros', () => { + const s = summary([], NOW); + assert.equal(s.trainsToday, 0); + assert.equal(s.sevenDayAvgPerDay, 0); + assert.equal(s.perDay, 0); +}); + +test('summary: counts trains within last 24h', () => { + const entries = [ + { ts: NOW - 1 * 60_000, delta: 100 }, // 1 min ago + { ts: NOW - 12 * 3_600_000, delta: 100 }, // 12h ago + { ts: NOW - 23 * 3_600_000, delta: 100 }, // 23h ago + { ts: NOW - 25 * 3_600_000, delta: 100 }, // 25h ago (not today) + ]; + const s = summary(entries, NOW); + assert.equal(s.trainsToday, 3); +}); + +test('summary: 7-day average is total/7', () => { + // 14 entries in last 7 days + const entries = []; + for (let i = 0; i < 14; i++) { + entries.push({ ts: NOW - i * DAY, delta: 100 }); + } + const s = summary(entries, NOW); + assert.equal(s.sevenDayAvgPerDay, 2); +}); + +test('summary: perDay uses most recent delta when available', () => { + const entries = [ + { ts: NOW - 1 * 60_000, delta: 247 }, + { ts: NOW - 12 * 3_600_000, delta: 300 }, + ]; + const s = summary(entries, NOW); + // 2 trains in last 7 days → 2/7 avg + // perDay = avg * most recent delta = (2/7) * 247 = ~70.57 + assert.equal(s.perDay, Math.floor((2 / 7) * 247)); +}); + +test('summary: perDay is 0 when no recent delta', () => { + const entries = [{ ts: NOW - 31 * DAY, delta: 100 }]; + const s = summary(entries, NOW); + assert.equal(s.perDay, 0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test` +Expected: FAIL — `summary is not a function`. + +- [ ] **Step 3: Implement `summary`** + +Append to `src/pure.js`: + +```js +export function summary(entries, now = Date.now()) { + if (entries.length === 0) { + return { trainsToday: 0, sevenDayAvgPerDay: 0, perDay: 0 }; + } + + const ONE_DAY = 86_400_000; + const SEVEN_DAYS = 7 * ONE_DAY; + + const todayCutoff = now - ONE_DAY; + const weekCutoff = now - SEVEN_DAYS; + + let trainsToday = 0; + let trainsWeek = 0; + let latestDelta = 0; + let latestTs = -Infinity; + + for (const e of entries) { + if (e.ts >= todayCutoff) trainsToday++; + if (e.ts >= weekCutoff) trainsWeek++; + if (e.ts > latestTs) { + latestTs = e.ts; + latestDelta = e.delta; + } + } + + const sevenDayAvgPerDay = trainsWeek / 7; + const perDay = latestDelta > 0 ? sevenDayAvgPerDay * latestDelta : 0; + + return { trainsToday, sevenDayAvgPerDay, perDay }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — all 5 `summary` test cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/pure.js tests/pure.test.js +git commit -m "feat(pure): summary with today/week counts and per-day rate" +``` + +--- + +## Task 5: Store — load/save and target getters/setters + +**Files:** +- Create: `src/store.js` +- Create: `tests/store.test.js` +- Create: `tests/localstorage-shim.js` + +The Store needs to be testable in Node. We provide a minimal `localStorage` shim and inject it via a constructor argument. + +- [ ] **Step 1: Create the localStorage shim** + +`tests/localstorage-shim.js`: + +```js +export function createLocalStorage() { + const data = new Map(); + return { + getItem(k) { return data.has(k) ? data.get(k) : null; }, + setItem(k, v) { data.set(k, String(v)); }, + removeItem(k) { data.delete(k); }, + clear() { data.clear(); }, + }; +} +``` + +- [ ] **Step 2: Write the failing test for load/save and target accessors** + +`tests/store.test.js`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Store } from '../src/store.js'; +import { createLocalStorage } from './localstorage-shim.js'; + +function freshStore() { + return new Store({ storage: createLocalStorage() }); +} + +test('Store: starts empty', () => { + const s = freshStore(); + assert.equal(s.getTarget('strength'), null); +}); + +test('Store: setTarget validates via parseTarget', () => { + const s = freshStore(); + assert.equal(s.setTarget('strength', '25M'), true); + assert.equal(s.getTarget('strength'), 25_000_000); +}); + +test('Store: setTarget rejects invalid and keeps previous', () => { + const s = freshStore(); + s.setTarget('strength', 25_000_000); + assert.equal(s.setTarget('strength', 'abc'), false); + assert.equal(s.getTarget('strength'), 25_000_000); +}); + +test('Store: targets are per-attribute', () => { + const s = freshStore(); + s.setTarget('strength', 25_000_000); + s.setTarget('speed', 50_000_000); + assert.equal(s.getTarget('strength'), 25_000_000); + assert.equal(s.getTarget('speed'), 50_000_000); +}); + +test('Store: persists across instances via storage', () => { + const storage = createLocalStorage(); + const a = new Store({ storage }); + a.setTarget('strength', 25_000_000); + const b = new Store({ storage }); + assert.equal(b.getTarget('strength'), 25_000_000); +}); + +test('Store: corrupted JSON is wiped with a warning', () => { + const storage = createLocalStorage(); + storage.setItem('tat.targets', '{not json'); + const warnings = []; + const s = new Store({ storage, onWarn: (m) => warnings.push(m) }); + assert.equal(s.getTarget('strength'), null); + assert.equal(warnings.length, 1); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm test` +Expected: FAIL — `Cannot find module '../src/store.js'`. + +- [ ] **Step 4: Implement `Store` with load/save and target accessors** + +`src/store.js`: + +```js +import { parseTarget } from './pure.js'; + +const KEY_TARGETS = 'tat.targets'; +const KEY_HISTORY = 'tat.history'; +const KEY_PREFS = 'tat.prefs'; + +const DEFAULT_PREFS = { version: 1, mode: 'free', pos: { x: 0, y: 0 } }; + +export class Store { + constructor({ storage, onWarn = (m) => console.warn(m) } = {}) { + if (!storage) throw new Error('Store requires a storage object'); + this.storage = storage; + this.onWarn = onWarn; + this.targets = this._loadJson(KEY_TARGETS, {}); + this.history = this._loadJson(KEY_HISTORY, {}); + this.prefs = this._mergePrefs(this._loadJson(KEY_PREFS, null)); + } + + _loadJson(key, fallback) { + let raw; + try { + raw = this.storage.getItem(key); + } catch { + return fallback; + } + if (raw == null) return fallback; + try { + return JSON.parse(raw); + } catch { + this.onWarn(`[tat] discarding corrupted ${key}`); + try { this.storage.removeItem(key); } catch { /* noop */ } + return fallback; + } + } + + _saveJson(key, value) { + try { + this.storage.setItem(key, JSON.stringify(value)); + return true; + } catch (e) { + this.onWarn(`[tat] failed to persist ${key}: ${e.message}`); + return false; + } + } + + _mergePrefs(loaded) { + if (!loaded || loaded.version !== 1) return { ...DEFAULT_PREFS }; + return { ...DEFAULT_PREFS, ...loaded }; + } + + getTarget(attr) { + const v = this.targets[attr]; + return typeof v === 'number' && v > 0 ? v : null; + } + + setTarget(attr, value) { + const parsed = parseTarget(value); + if (parsed === null) return false; + this.targets[attr] = parsed; + return this._saveJson(KEY_TARGETS, this.targets); + } + + getPrefs() { + return { ...this.prefs }; + } + + setMode(mode) { + if (mode !== 'free' && mode !== 'anchored') return false; + this.prefs.mode = mode; + return this._saveJson(KEY_PREFS, this.prefs); + } + + setPos(pos) { + if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') return false; + this.prefs.pos = { x: pos.x, y: pos.y }; + return this._saveJson(KEY_PREFS, this.prefs); + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — all 6 `Store` test cases. + +- [ ] **Step 6: Commit** + +```bash +git add src/store.js tests/store.test.js tests/localstorage-shim.js +git commit -m "feat(store): load/save and target accessors with validation" +``` + +--- + +## Task 6: Store — recordTrain and summary + +**Files:** +- Modify: `src/store.js` +- Modify: `tests/store.test.js` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/store.test.js`: + +```js +import { summary } from '../src/pure.js'; + +const DAY = 86_400_000; +const NOW = 1_700_000_000_000; + +test('Store: recordTrain appends to per-attribute history', () => { + const s = freshStore(); + s.recordTrain('strength', 247, NOW); + s.recordTrain('strength', 250, NOW - 1000); + assert.equal(s.history.strength.length, 2); + assert.equal(s.history.strength[0].delta, 247); +}); + +test('Store: recordTrain prunes entries older than 30 days', () => { + const s = freshStore(); + s.recordTrain('strength', 100, NOW - 31 * DAY); + s.recordTrain('strength', 200, NOW); + assert.equal(s.history.strength.length, 1); + assert.equal(s.history.strength[0].delta, 200); +}); + +test('Store: getSummary returns computed summary for attribute', () => { + const s = freshStore(); + // 2 trains today + s.recordTrain('strength', 247, NOW - 1000); + s.recordTrain('strength', 247, NOW - 2000); + const sum = s.getSummary('strength', NOW); + assert.equal(sum.trainsToday, 2); + assert.equal(sum.perDay, (2 / 7) * 247); +}); +``` + +Note: `summary` import is already at the top, so just append tests after adjusting the existing import. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test` +Expected: FAIL — `s.recordTrain is not a function`. + +- [ ] **Step 3: Implement `recordTrain` and `getSummary`** + +Append to the `Store` class in `src/store.js`: + +```js +import { pruneHistory, summary as computeSummary } from './pure.js'; + +// inside the class: + +recordTrain(attr, delta, ts = Date.now()) { + if (typeof delta !== 'number' || !Number.isFinite(delta) || delta <= 0) { + return false; + } + const list = this.history[attr] || []; + list.push({ ts, delta }); + this.history[attr] = pruneHistory(list, ts); + return this._saveJson(KEY_HISTORY, this.history); +} + +getSummary(attr, now = Date.now()) { + const list = this.history[attr] || []; + return computeSummary(list, now); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — all 9 `Store` test cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/store.js tests/store.test.js +git commit -m "feat(store): recordTrain with pruning and getSummary" +``` + +--- + +## Task 7: DOM scraper for current attribute + +**Files:** +- Create: `src/dom.js` + +`currentAttribute()` reads the gym page. Torn's gym page selector is brittle and we don't have a live page in the test environment, so this is not unit-tested in Node. We mark the brittle points clearly so a human can verify them in the manual test task. + +- [ ] **Step 1: Implement `currentAttribute`** + +`src/dom.js`: + +```js +/** + * Reads the gym page DOM and returns: + * { attr: 'strength'|'speed'|..., current: number, gym: string } + * or `null` if the page doesn't look like a Torn gym page. + * + * The selectors below are best-effort matches for torn.com/gym.php + * and will need adjustment if Torn changes the markup. + */ +export function currentAttribute() { + // The attribute name is shown in the gym page header. + // Torn displays it as a capitalized word (e.g. "Strength") near the + // top of the gym form. + const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence']; + + const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]'); + let attr = null; + for (const el of headers) { + const t = (el.textContent || '').trim().toLowerCase(); + for (const k of KNOWN) { + if (t.includes(k)) { attr = k; break; } + } + if (attr) break; + } + if (!attr) return null; + + // Current value: look for the prominent number on the page that is + // formatted like a Torn attribute (e.g. "14,328,501"). + const valEl = findValueElement(); + if (!valEl) return null; + const current = parseNumber(valEl.textContent); + if (current == null) return null; + + // Gym name: any element on the page containing the word "Gym" or + // "Bastion" / "Frontline" / etc. Torn's gym names vary. + const gym = findGymName() || 'Unknown gym'; + + return { attr, current, gym }; +} + +function findValueElement() { + // Walk all elements; pick the largest formatted number on the page. + // Torn renders the current attribute as a big number near the + // "Property" label. + const candidates = document.querySelectorAll('*'); + let best = null; + let bestN = -Infinity; + for (const el of candidates) { + if (el.children.length > 0) continue; + const t = (el.textContent || '').trim(); + if (!/^[\d,]+(\.\d+)?$/.test(t)) continue; + const n = parseNumber(t); + if (n == null || n < 1) continue; + if (n > bestN) { best = el; bestN = n; } + } + return best; +} + +function findGymName() { + // Look for a known set of Torn gym name fragments. Adjust as needed. + const known = [ + 'Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500', + 'Baldr\'s Gym', 'Sportscience Laboratory', 'Premier Fitness', + 'Chrome Gym', 'Mr. Miyagi\'s', 'Power House', + ]; + const all = document.querySelectorAll('p, span, div, h1, h2, h3, h4, li'); + for (const el of all) { + const t = (el.textContent || '').trim(); + for (const name of known) { + if (t.includes(name)) return name; + } + } + return null; +} + +function parseNumber(text) { + if (!text) return null; + const cleaned = text.replace(/,/g, '').trim(); + if (!/^\d+(\.\d+)?$/.test(cleaned)) return null; + const n = parseFloat(cleaned); + return Number.isFinite(n) ? Math.floor(n) : null; +} +``` + +- [ ] **Step 2: Verify file loads without parse errors** + +Run: `node --check src/dom.js` +Expected: silent exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/dom.js +git commit -m "feat(dom): currentAttribute scraper for gym page (manual verify)" +``` + +--- + +## Task 8: Train request interceptor + +**Files:** +- Create: `src/interceptor.js` + +Wraps `window.XMLHttpRequest` and `window.fetch` to detect the Train request. The exact URL and response shape can be discovered by opening DevTools → Network on a gym page and clicking Train. We expose a parser function so the manual test task can verify and tune it. + +- [ ] **Step 1: Implement the interceptor** + +`src/interceptor.js`: + +```js +/** + * Wraps XHR and fetch to listen for the request Torn sends when the + * user clicks "Train". When such a request is detected, the response + * is parsed via `parseTrainResponse`, the new attribute value is + * compared against the previous one, and `onTrain({attr, delta, ts})` + * is invoked. + * + * `parseTrainResponse(text, url)` is intentionally permissive and + * returns `{ newValue, attr } | null`. The default implementation + * tries JSON first, then a regex fallback. + */ +export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) { + let lastValue = prevValue; + let warnedFor = null; + + const handle = (text, url) => { + const parsed = parseTrainResponse(text, url); + if (!parsed) { + if (warnedFor !== url) { + warnedFor = url; + onParseFail && onParseFail(url); + } + return; + } + if (parsed.attr !== currentAttr) return; + const delta = parsed.newValue - lastValue; + lastValue = parsed.newValue; + if (delta > 0) onTrain({ attr: parsed.attr, delta, ts: Date.now() }); + }; + + wrapXhr(handle); + wrapFetch(handle); + + return { + updatePrevValue(v) { lastValue = v; }, + }; +} + +function parseTrainResponse(text, url) { + // Try JSON + try { + const j = JSON.parse(text); + // Torn historically returns an HTML fragment; if it's JSON, look + // for a known shape. This is a placeholder — adjust after manual + // verification. + if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) { + return { newValue: Number(j.newValue), attr: String(j.attr) }; + } + } catch { /* not JSON */ } + + // Fallback: scan text for a number formatted like an attribute. + const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/); + if (m) { + const newValue = parseInt(m[1].replace(/,/g, ''), 10); + if (Number.isFinite(newValue) && newValue > 0) { + // Without a confirmed attr, fall back to whatever currentAttr + // the caller passed in. + return { newValue, attr: null }; + } + } + return null; +} + +function wrapXhr(handle) { + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { + this.__tatUrl = String(url); + return origOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + this.addEventListener('load', () => { + try { handle(this.responseText, this.__tatUrl); } catch { /* noop */ } + }); + return origSend.apply(this, arguments); + }; +} + +function wrapFetch(handle) { + const origFetch = window.fetch; + if (origFetch.__tatWrapped) return; + window.fetch = async function (...args) { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; + const res = await origFetch.apply(this, args); + try { + const text = await res.clone().text(); + handle(text, String(url)); + } catch { /* noop */ } + return res; + }; + window.fetch.__tatWrapped = true; +} +``` + +- [ ] **Step 2: Verify file loads without parse errors** + +Run: `node --check src/interceptor.js` +Expected: silent exit 0. (Note: this only checks syntax; `XMLHttpRequest` and `window` references are browser-only and will fail at runtime in Node.) + +- [ ] **Step 3: Commit** + +```bash +git add src/interceptor.js +git commit -m "feat(interceptor): XHR/fetch wrap to detect Train responses" +``` + +--- + +## Task 9: Dialog UI — mount, render, drag + +**Files:** +- Create: `src/ui.js` + +- [ ] **Step 1: Implement the Dialog class** + +`src/ui.js`: + +```js +import { computeEstimate } from './pure.js'; + +const STYLE = ` +.tat-root { + position: fixed; z-index: 99999; min-width: 320px; max-width: 420px; + background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + font: 13px/1.4 Tahoma, Verdana, sans-serif; + padding: 12px 14px; +} +.tat-header { + display: flex; justify-content: space-between; align-items: center; + padding-bottom: 8px; margin-bottom: 10px; + border-bottom: 1px solid #444; + cursor: move; user-select: none; +} +.tat-header strong { color: #fff; } +.tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; } +.tat-close:hover { opacity: 1; } +.tat-row { display: flex; justify-content: space-between; padding: 2px 0; } +.tat-row.tat-target input, .tat-row.tat-target select { + background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px; + font: inherit; font-size: 12px; +} +.tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; } +.tat-modes { display: flex; gap: 6px; margin-top: 12px; } +.tat-modes button { + flex: 1; padding: 4px; background: #2b2b2b; color: #ddd; + border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer; +} +.tat-modes button.active { background: #444; border-color: #888; } +.tat-warn { color: #c90; margin-top: 6px; font-size: 11px; } +.tat-error { padding: 8px 0; color: #f88; } +.tat-error button { margin-left: 8px; } +`; + +const MILESTONES = [ + { label: 'Custom', value: null }, + { label: '1M', value: 1_000_000 }, + { label: '5M', value: 5_000_000 }, + { label: '10M', value: 10_000_000 }, + { label: '25M', value: 25_000_000 }, + { label: '50M', value: 50_000_000 }, + { label: '100M', value: 100_000_000 }, + { label: '250M', value: 250_000_000 }, + { label: '500M', value: 500_000_000 }, + { label: '1B', value: 1_000_000_000 }, +]; + +function fmt(n) { + if (n == null) return '—'; + if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K'; + return String(n); +} + +function fmtFull(n) { + if (n == null) return '—'; + return Math.round(n).toLocaleString('en-US'); +} + +function fmtDate(d) { + if (!d) return '—'; + return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }); +} + +export class Dialog { + constructor({ onTargetChange, onModeChange, onPosChange, onClose } = {}) { + this.onTargetChange = onTargetChange; + this.onModeChange = onModeChange; + this.onPosChange = onPosChange; + this.onClose = onClose; + this.root = null; + this.dragState = null; + this.mode = 'free'; + this.warn = null; + } + + mount({ initialMode = 'free', initialPos = { x: 0, y: 0 } } = {}) { + if (this.root) return; + injectStyles(); + + const root = document.createElement('div'); + root.className = 'tat-root'; + root.dataset.tat = '1'; + document.body.appendChild(root); + this.root = root; + this.mode = initialMode; + + if (initialMode === 'free') { + root.style.bottom = '20px'; + root.style.right = '20px'; + if (initialPos.x || initialPos.y) { + root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`; + } + } + + this._wireHeaderDrag(); + } + + destroy() { + if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); + this.root = null; + } + + setMode(mode, anchorInfo) { + this.mode = mode; + if (!this.root) return; + this.root.style.transform = ''; + this.root.style.top = ''; + this.root.style.bottom = ''; + this.root.style.left = ''; + this.root.style.right = ''; + if (mode === 'free') { + this.root.style.bottom = '20px'; + this.root.style.right = '20px'; + } else if (anchorInfo && anchorInfo.canAnchor) { + this._positionAnchored(anchorInfo.rect); + } else { + this.root.style.top = '20px'; + this.root.style.left = '50%'; + this.root.style.transform = 'translateX(-50%)'; + } + } + + _positionAnchored(rect) { + const dialogRect = this.root.getBoundingClientRect(); + let top = rect.top - dialogRect.height - 8; + if (top < 8) top = 20; + let left = rect.left + (rect.width - dialogRect.width) / 2; + if (left < 8) left = 8; + if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8; + this.root.style.top = `${top}px`; + this.root.style.left = `${left}px`; + } + + _wireHeaderDrag() { + const onDown = (e) => { + if (this.mode !== 'free') return; + if (e.target.classList.contains('tat-close')) return; + const rect = this.root.getBoundingClientRect(); + this.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }; + e.preventDefault(); + }; + const onMove = (e) => { + if (!this.dragState) return; + const x = e.clientX - this.dragState.dx; + const y = e.clientY - this.dragState.dy; + this.root.style.left = `${x}px`; + this.root.style.top = `${y}px`; + this.root.style.right = 'auto'; + this.root.style.bottom = 'auto'; + }; + const onUp = () => { + if (!this.dragState) return; + const rect = this.root.getBoundingClientRect(); + this.dragState = null; + this.onPosChange && this.onPosChange({ x: rect.left, y: rect.top }); + }; + this.root.addEventListener('mousedown', onDown); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + render(state) { + if (!this.root) return; + const { attr, gym, current, target, perTrain, summary, error, warn } = state; + if (error) { + this.root.innerHTML = ` +
⚙ Attribute Tracker
+
${error}
+ `; + this.root.querySelector('[data-action="reload"]').onclick = () => location.reload(); + this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose(); + return; + } + + const est = computeEstimate(current, target || 0, perTrain || 0, summary?.perDay || 0); + + const modes = ['free', 'anchored'].map((m) => + `` + ).join(''); + + const milestoneOptions = MILESTONES.map((m) => { + const sel = m.value === target ? 'selected' : ''; + return ``; + }).join(''); + + this.root.innerHTML = ` +
+ ⚙ Attribute Tracker + +
+
Attribute${attr || '—'} · ${gym || '—'}
+
Current${fmtFull(current)}
+
+ Target + + + + +
+
+
Per train${perTrain ? '+ ' + fmtFull(perTrain) : '—'}
+
Trains today${fmtFull(summary?.trainsToday ?? 0)}
+
7-day avg${summary ? summary.sevenDayAvgPerDay.toFixed(1) : '0.0'} / day
+
Per day${summary && summary.perDay > 0 ? '+ ' + fmtFull(summary.perDay) : '—'}
+
+
Remaining${fmtFull(est.remaining)}
+
Trains to go≈ ${fmtFull(est.trainsToGo)}
+
ETA${est.days > 0 ? `~ ${fmtFull(est.days)} days (${fmtDate(est.eta)})` : '—'}
+
${modes}
+ ${warn ? `
⚠ ${warn}
` : ''} + `; + + this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose(); + this.root.querySelector('[data-role="target"]').onchange = (e) => { + this.onTargetChange && this.onTargetChange(e.target.value); + }; + this.root.querySelector('[data-role="milestone"]').onchange = (e) => { + const v = e.target.value; + if (v !== '') this.onTargetChange && this.onTargetChange(Number(v)); + }; + this.root.querySelectorAll('[data-mode]').forEach((btn) => { + btn.onclick = () => this.onModeChange && this.onModeChange(btn.dataset.mode); + }); + } +} + +let styleInjected = false; +function injectStyles() { + if (styleInjected) return; + const s = document.createElement('style'); + s.textContent = STYLE; + document.head.appendChild(s); + styleInjected = true; +} +``` + +- [ ] **Step 2: Verify file loads without parse errors** + +Run: `node --check src/ui.js` +Expected: silent exit 0. (Browser DOM globals referenced; runtime test is in the manual test task.) + +- [ ] **Step 3: Commit** + +```bash +git add src/ui.js +git commit -m "feat(ui): Dialog with render, drag, mode toggle, milestone dropdown" +``` + +--- + +## Task 10: Main wiring + +**Files:** +- Create: `src/main.js` + +- [ ] **Step 1: Implement main** + +`src/main.js`: + +```js +import { Store } from './store.js'; +import { Dialog } from './ui.js'; +import { currentAttribute } from './dom.js'; +import { startRequestInterceptor } from './interceptor.js'; + +function findAnchorElement() { + // Torn's training form is the element containing the Train button. + // Selector is best-effort; the Dialog will fall back if missing. + const btn = document.querySelector('button[name="train"], a[href*="train"]'); + if (!btn) return null; + return btn.closest('form') || btn.parentElement; +} + +function start() { + const store = new Store({ + storage: localStorage, + onWarn: (m) => console.warn(m), + }); + const prefs = store.getPrefs(); + + const dialog = new Dialog({ + onTargetChange: (v) => { + const attr = currentAttribute()?.attr; + if (!attr) return; + store.setTarget(attr, v); + render(); + }, + onModeChange: (m) => { + store.setMode(m); + applyMode(); + }, + onPosChange: (pos) => store.setPos(pos), + onClose: () => dialog.destroy(), + }); + + dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos }); + applyMode(); + + let lastSnapshot = null; + let lastAttr = null; + let lastDelta = 0; + + function snapshot() { + const a = currentAttribute(); + if (!a) { + return { error: "Couldn't read attribute — Torn may have updated the page." }; + } + lastAttr = a.attr; + const summary = store.getSummary(a.attr); + return { + attr: a.attr, + gym: a.gym, + current: a.current, + target: store.getTarget(a.attr), + perTrain: lastDelta, + summary, + warn: store._saveDisabled ? 'saving disabled this session' : null, + }; + } + + function render() { + lastSnapshot = snapshot(); + dialog.render(lastSnapshot); + } + + function applyMode() { + if (prefs.mode === 'anchored') { + const el = findAnchorElement(); + if (el) { + const rect = el.getBoundingClientRect(); + dialog.setMode('anchored', { canAnchor: true }); + dialog._positionAnchored(rect); + // observe + const ro = new ResizeObserver(() => { + if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect()); + }); + ro.observe(el); + return; + } + } + dialog.setMode('free'); + } + + // initial paint + render(); + + // watch DOM for attribute changes + const observer = new MutationObserver(() => { + const a = currentAttribute(); + if (a && (a.attr !== lastAttr || a.current !== lastSnapshot?.current)) render(); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + // intercept train requests + const prev = currentAttribute()?.current ?? 0; + startRequestInterceptor({ + prevValue: prev, + currentAttr: lastAttr, + onTrain: ({ attr, delta, ts }) => { + store.recordTrain(attr, delta, ts); + lastDelta = delta; + render(); + }, + onParseFail: (url) => console.warn('[tat] could not parse train response from', url), + }); +} + +if (location.hash === '#tat-test') { + // Self-test path: the in-browser tests live in the bundled userscript. + // The bundle runs the test block; nothing to do here. +} else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) { + start(); +} +``` + +- [ ] **Step 2: Verify file loads without parse errors** + +Run: `node --check src/main.js` +Expected: silent exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/main.js +git commit -m "feat(main): wire Store + Dialog + DataLayer on /gym.php" +``` + +--- + +## Task 11: Bundle the userscript + +**Files:** +- Create: `torn-attribute-tracker.user.js` + +Concatenate `src/pure.js`, `src/store.js`, `src/dom.js`, `src/interceptor.js`, `src/ui.js`, `src/main.js` into a single userscript with a Tampermonkey header and the `#tat-test` self-test block. This is done by hand here (not a build step) so the engineer can see the full result. + +- [ ] **Step 1: Create the bundled userscript** + +`torn-attribute-tracker.user.js`: + +```js +// ==UserScript== +// @name Torn Attribute Training Tracker +// @namespace https://github.com/local/torn-attribute-tracker +// @version 0.1.0 +// @description Floating dialog showing attribute target, rate, and ETA on the Torn gym page. +// @match https://www.torn.com/gym.php* +// @grant none +// @run-at document-end +// ==/UserScript== + +(function () { + 'use strict'; + + // ===== pure.js (embedded) ===== + const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }; + + function parseTarget(input) { + if (input === null || input === undefined || input === '') return null; + if (typeof input === 'number') { + if (!Number.isFinite(input) || input <= 0) return null; + return Math.floor(input); + } + if (typeof input !== 'string') return null; + const cleaned = input.replace(/,/g, '').trim().toLowerCase(); + if (cleaned === '') return null; + if (!/^\d+(\.\d+)?[kmbt]?$/.test(cleaned)) return null; + const match = cleaned.match(/^(\d+(?:\.\d+)?)([kmbt])?$/); + if (!match) return null; + const num = parseFloat(match[1]); + if (!Number.isFinite(num) || num <= 0) return null; + const suffix = match[2]; + const multiplier = suffix ? SUFFIXES[suffix] : 1; + return Math.floor(num * multiplier); + } + + const THIRTY_DAYS_MS = 30 * 86_400_000; + + function pruneHistory(entries, now) { + const cutoff = (now || Date.now()) - THIRTY_DAYS_MS; + return entries.filter((e) => e.ts >= cutoff); + } + + function computeEstimate(current, target, perTrain, perDay) { + const remaining = Math.max(0, target - current); + if (remaining === 0) return { remaining: 0, trainsToGo: 0, days: 0, eta: null }; + const trainsToGo = perTrain > 0 ? Math.ceil(remaining / perTrain) : 0; + const days = perDay > 0 ? Math.ceil(remaining / perDay) : 0; + const eta = days > 0 ? new Date(Date.now() + days * 86_400_000) : null; + return { remaining, trainsToGo, days, eta }; + } + + function summary(entries, now) { + const t = now || Date.now(); + if (entries.length === 0) return { trainsToday: 0, sevenDayAvgPerDay: 0, perDay: 0 }; + const ONE_DAY = 86_400_000, SEVEN_DAYS = 7 * ONE_DAY; + const todayCutoff = t - ONE_DAY, weekCutoff = t - SEVEN_DAYS; + let trainsToday = 0, trainsWeek = 0, latestDelta = 0, latestTs = -Infinity; + for (const e of entries) { + if (e.ts >= todayCutoff) trainsToday++; + if (e.ts >= weekCutoff) trainsWeek++; + if (e.ts > latestTs) { latestTs = e.ts; latestDelta = e.delta; } + } + const sevenDayAvgPerDay = trainsWeek / 7; + const perDay = latestDelta > 0 ? sevenDayAvgPerDay * latestDelta : 0; + return { trainsToday, sevenDayAvgPerDay, perDay }; + } + + // ===== store.js (embedded) ===== + const KEY_TARGETS = 'tat.targets'; + const KEY_HISTORY = 'tat.history'; + const KEY_PREFS = 'tat.prefs'; + const DEFAULT_PREFS = { version: 1, mode: 'free', pos: { x: 0, y: 0 } }; + + class Store { + constructor(opts) { + opts = opts || {}; + const storage = opts.storage || localStorage; + const onWarn = opts.onWarn || function (m) { console.warn(m); }; + if (!storage) throw new Error('Store requires storage'); + this.storage = storage; + this.onWarn = onWarn; + this._saveDisabled = false; + this.targets = this._loadJson(KEY_TARGETS, {}); + this.history = this._loadJson(KEY_HISTORY, {}); + this.prefs = this._mergePrefs(this._loadJson(KEY_PREFS, null)); + } + _loadJson(key, fallback) { + let raw; + try { raw = this.storage.getItem(key); } catch { return fallback; } + if (raw == null) return fallback; + try { return JSON.parse(raw); } + catch { this.onWarn('[tat] discarding corrupted ' + key); try { this.storage.removeItem(key); } catch {} return fallback; } + } + _saveJson(key, value) { + try { this.storage.setItem(key, JSON.stringify(value)); return true; } + catch (e) { this.onWarn('[tat] failed to persist ' + key + ': ' + e.message); this._saveDisabled = true; return false; } + } + _mergePrefs(loaded) { + if (!loaded || loaded.version !== 1) return Object.assign({}, DEFAULT_PREFS); + return Object.assign({}, DEFAULT_PREFS, loaded); + } + getTarget(attr) { const v = this.targets[attr]; return typeof v === 'number' && v > 0 ? v : null; } + setTarget(attr, value) { + const parsed = parseTarget(value); + if (parsed === null) return false; + this.targets[attr] = parsed; + return this._saveJson(KEY_TARGETS, this.targets); + } + getSummary(attr, now) { return summary(this.history[attr] || [], now); } + recordTrain(attr, delta, ts) { + ts = ts || Date.now(); + if (typeof delta !== 'number' || !Number.isFinite(delta) || delta <= 0) return false; + const list = this.history[attr] || []; + list.push({ ts: ts, delta: delta }); + this.history[attr] = pruneHistory(list, ts); + return this._saveJson(KEY_HISTORY, this.history); + } + getPrefs() { return Object.assign({}, this.prefs); } + setMode(mode) { if (mode !== 'free' && mode !== 'anchored') return false; this.prefs.mode = mode; return this._saveJson(KEY_PREFS, this.prefs); } + setPos(pos) { if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') return false; this.prefs.pos = { x: pos.x, y: pos.y }; return this._saveJson(KEY_PREFS, this.prefs); } + } + + // ===== dom.js (embedded) ===== + function currentAttribute() { + const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence']; + const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]'); + let attr = null; + for (const el of headers) { + const t = (el.textContent || '').trim().toLowerCase(); + for (const k of KNOWN) { if (t.includes(k)) { attr = k; break; } } + if (attr) break; + } + if (!attr) return null; + const valEl = findValueElement(); + if (!valEl) return null; + const current = parseNumber(valEl.textContent); + if (current == null) return null; + const gym = findGymName() || 'Unknown gym'; + return { attr: attr, current: current, gym: gym }; + } + function findValueElement() { + const candidates = document.querySelectorAll('*'); + let best = null, bestN = -Infinity; + for (const el of candidates) { + if (el.children.length > 0) continue; + const t = (el.textContent || '').trim(); + if (!/^[\d,]+(\.\d+)?$/.test(t)) continue; + const n = parseNumber(t); + if (n == null || n < 1) continue; + if (n > bestN) { best = el; bestN = n; } + } + return best; + } + function findGymName() { + const known = ['Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500', "Baldr's Gym", 'Sportscience Laboratory', 'Premier Fitness', 'Chrome Gym', "Mr. Miyagi's", 'Power House']; + const all = document.querySelectorAll('p, span, div, h1, h2, h3, h4, li'); + for (const el of all) { + const t = (el.textContent || '').trim(); + for (const name of known) { if (t.includes(name)) return name; } + } + return null; + } + function parseNumber(text) { + if (!text) return null; + const cleaned = text.replace(/,/g, '').trim(); + if (!/^\d+(\.\d+)?$/.test(cleaned)) return null; + const n = parseFloat(cleaned); + return Number.isFinite(n) ? Math.floor(n) : null; + } + + // ===== interceptor.js (embedded) ===== + function startRequestInterceptor(opts) { + let lastValue = opts.prevValue; + let warnedFor = null; + function handle(text, url) { + const parsed = parseTrainResponse(text, url); + if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; } + if (opts.currentAttr && parsed.attr && parsed.attr !== opts.currentAttr) return; + const delta = parsed.newValue - lastValue; + lastValue = parsed.newValue; + if (delta > 0) opts.onTrain({ attr: parsed.attr || opts.currentAttr, delta: delta, ts: Date.now() }); + } + wrapXhr(handle); wrapFetch(handle); + return { updatePrevValue: function (v) { lastValue = v; } }; + } + function parseTrainResponse(text, url) { + try { + const j = JSON.parse(text); + if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) { + return { newValue: Number(j.newValue), attr: String(j.attr) }; + } + } catch {} + const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/); + if (m) { + const newValue = parseInt(m[1].replace(/,/g, ''), 10); + if (Number.isFinite(newValue) && newValue > 0) return { newValue: newValue, attr: null }; + } + return null; + } + function wrapXhr(handle) { + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { this.__tatUrl = String(url); return origOpen.apply(this, arguments); }; + XMLHttpRequest.prototype.send = function () { + this.addEventListener('load', function () { try { handle(this.responseText, this.__tatUrl); } catch {} }); + return origSend.apply(this, arguments); + }; + } + function wrapFetch(handle) { + const origFetch = window.fetch; + if (origFetch.__tatWrapped) return; + window.fetch = async function () { + const url = typeof arguments[0] === 'string' ? arguments[0] : (arguments[0] && arguments[0].url) || ''; + const res = await origFetch.apply(this, arguments); + try { const text = await res.clone().text(); handle(text, String(url)); } catch {} + return res; + }; + window.fetch.__tatWrapped = true; + } + + // ===== ui.js (embedded) ===== + const TAT_STYLE = ` + .tat-root { position: fixed; z-index: 99999; min-width: 320px; max-width: 420px; background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font: 13px/1.4 Tahoma, Verdana, sans-serif; padding: 12px 14px; } + .tat-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1px solid #444; cursor: move; user-select: none; } + .tat-header strong { color: #fff; } + .tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; } + .tat-close:hover { opacity: 1; } + .tat-row { display: flex; justify-content: space-between; padding: 2px 0; } + .tat-row.tat-target input, .tat-row.tat-target select { background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px; font: inherit; font-size: 12px; } + .tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; } + .tat-modes { display: flex; gap: 6px; margin-top: 12px; } + .tat-modes button { flex: 1; padding: 4px; background: #2b2b2b; color: #ddd; border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer; } + .tat-modes button.active { background: #444; border-color: #888; } + .tat-warn { color: #c90; margin-top: 6px; font-size: 11px; } + .tat-error { padding: 8px 0; color: #f88; } + .tat-error button { margin-left: 8px; } + `; + + const TAT_MILESTONES = [ + { label: 'Custom', value: null }, + { label: '1M', value: 1_000_000 }, { label: '5M', value: 5_000_000 }, + { label: '10M', value: 10_000_000 }, { label: '25M', value: 25_000_000 }, + { label: '50M', value: 50_000_000 }, { label: '100M', value: 100_000_000 }, + { label: '250M', value: 250_000_000 }, { label: '500M', value: 500_000_000 }, + { label: '1B', value: 1_000_000_000 }, + ]; + + function tatFmt(n) { + if (n == null) return '—'; + if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K'; + return String(n); + } + function tatFmtFull(n) { if (n == null) return '—'; return Math.round(n).toLocaleString('en-US'); } + function tatFmtDate(d) { if (!d) return '—'; return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }); } + + class Dialog { + constructor(opts) { + opts = opts || {}; + this.onTargetChange = opts.onTargetChange; + this.onModeChange = opts.onModeChange; + this.onPosChange = opts.onPosChange; + this.onClose = opts.onClose; + this.root = null; this.dragState = null; this.mode = 'free'; + } + mount(opts) { + opts = opts || {}; + if (this.root) return; + if (typeof document === 'undefined') return; + if (!document.getElementById('tat-style')) { + const s = document.createElement('style'); s.id = 'tat-style'; s.textContent = TAT_STYLE; document.head.appendChild(s); + } + const root = document.createElement('div'); + root.className = 'tat-root'; + root.dataset.tat = '1'; + document.body.appendChild(root); + this.root = root; + this.mode = opts.initialMode || 'free'; + if (this.mode === 'free') { + root.style.bottom = '20px'; + root.style.right = '20px'; + if (opts.initialPos && (opts.initialPos.x || opts.initialPos.y)) { + root.style.transform = 'translate(' + opts.initialPos.x + 'px, ' + opts.initialPos.y + 'px)'; + } + } + this._wireHeaderDrag(); + } + destroy() { if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); this.root = null; } + setMode(mode, anchorInfo) { + this.mode = mode; + if (!this.root) return; + this.root.style.transform = ''; this.root.style.top = ''; this.root.style.bottom = ''; this.root.style.left = ''; this.root.style.right = ''; + if (mode === 'free') { this.root.style.bottom = '20px'; this.root.style.right = '20px'; } + else if (anchorInfo && anchorInfo.canAnchor) { this._positionAnchored(anchorInfo.rect); } + else { this.root.style.top = '20px'; this.root.style.left = '50%'; this.root.style.transform = 'translateX(-50%)'; } + } + _positionAnchored(rect) { + const dialogRect = this.root.getBoundingClientRect(); + let top = rect.top - dialogRect.height - 8; + if (top < 8) top = 20; + let left = rect.left + (rect.width - dialogRect.width) / 2; + if (left < 8) left = 8; + if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8; + this.root.style.top = top + 'px'; this.root.style.left = left + 'px'; + } + _wireHeaderDrag() { + const self = this; + this.root.addEventListener('mousedown', function (e) { + if (self.mode !== 'free') return; + if (e.target.classList.contains('tat-close')) return; + const rect = self.root.getBoundingClientRect(); + self.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }; + e.preventDefault(); + }); + document.addEventListener('mousemove', function (e) { + if (!self.dragState) return; + const x = e.clientX - self.dragState.dx, y = e.clientY - self.dragState.dy; + self.root.style.left = x + 'px'; self.root.style.top = y + 'px'; + self.root.style.right = 'auto'; self.root.style.bottom = 'auto'; + }); + document.addEventListener('mouseup', function () { + if (!self.dragState) return; + const rect = self.root.getBoundingClientRect(); + self.dragState = null; + self.onPosChange && self.onPosChange({ x: rect.left, y: rect.top }); + }); + } + render(state) { + if (!this.root) return; + const s = state; + if (s.error) { + this.root.innerHTML = '
⚙ Attribute Tracker
' + s.error + '
'; + this.root.querySelector('[data-action="reload"]').onclick = function () { location.reload(); }; + this.root.querySelector('.tat-close').onclick = function () { /* close handler */ }; + return; + } + const est = computeEstimate(s.current, s.target || 0, s.perTrain || 0, (s.summary && s.summary.perDay) || 0); + const modes = ['free', 'anchored'].map(function (m) { + return ''; + }, this).join(''); + const milestoneOptions = TAT_MILESTONES.map(function (m) { + const sel = m.value === s.target ? 'selected' : ''; + return ''; + }).join(''); + this.root.innerHTML = '' + + '
⚙ Attribute Tracker
' + + '
Attribute' + (s.attr || '—') + ' · ' + (s.gym || '—') + '
' + + '
Current' + tatFmtFull(s.current) + '
' + + '
Target
' + + '
' + + '
Per train' + (s.perTrain ? '+ ' + tatFmtFull(s.perTrain) : '—') + '
' + + '
Trains today' + tatFmtFull(s.summary && s.summary.trainsToday || 0) + '
' + + '
7-day avg' + (s.summary ? s.summary.sevenDayAvgPerDay.toFixed(1) : '0.0') + ' / day
' + + '
Per day' + (s.summary && s.summary.perDay > 0 ? '+ ' + tatFmtFull(s.summary.perDay) : '—') + '
' + + '
' + + '
Remaining' + tatFmtFull(est.remaining) + '
' + + '
Trains to go≈ ' + tatFmtFull(est.trainsToGo) + '
' + + '
ETA' + (est.days > 0 ? '~ ' + tatFmtFull(est.days) + ' days (' + tatFmtDate(est.eta) + ')' : '—') + '
' + + '
' + modes + '
' + + (s.warn ? '
⚠ ' + s.warn + '
' : ''); + const self = this; + this.root.querySelector('.tat-close').onclick = function () { self.onClose && self.onClose(); }; + this.root.querySelector('[data-role="target"]').onchange = function (e) { self.onTargetChange && self.onTargetChange(e.target.value); }; + this.root.querySelector('[data-role="milestone"]').onchange = function (e) { const v = e.target.value; if (v !== '') self.onTargetChange && self.onTargetChange(Number(v)); }; + this.root.querySelectorAll('[data-mode]').forEach(function (btn) { btn.onclick = function () { self.onModeChange && self.onModeChange(btn.dataset.mode); }; }); + } + } + + // ===== main.js (embedded) ===== + function findAnchorElement() { + const btn = document.querySelector('button[name="train"], a[href*="train"]'); + if (!btn) return null; + return btn.closest('form') || btn.parentElement; + } + + function start() { + const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } }); + const prefs = store.getPrefs(); + + const dialog = new Dialog({ + onTargetChange: function (v) { + const a = currentAttribute(); if (!a) return; store.setTarget(a.attr, v); render(); + }, + onModeChange: function (m) { store.setMode(m); applyMode(); }, + onPosChange: function (pos) { store.setPos(pos); }, + onClose: function () { dialog.destroy(); }, + }); + + dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos }); + applyMode(); + + let lastSnapshot = null; + let lastAttr = null; + let lastDelta = 0; + + function snapshot() { + const a = currentAttribute(); + if (!a) return { error: "Couldn't read attribute — Torn may have updated the page." }; + lastAttr = a.attr; + const summary = store.getSummary(a.attr); + return { + attr: a.attr, gym: a.gym, current: a.current, + target: store.getTarget(a.attr), perTrain: lastDelta, summary: summary, + warn: store._saveDisabled ? 'saving disabled this session' : null, + }; + } + + function render() { lastSnapshot = snapshot(); dialog.render(lastSnapshot); } + + function applyMode() { + if (prefs.mode === 'anchored') { + const el = findAnchorElement(); + if (el) { + const rect = el.getBoundingClientRect(); + dialog.setMode('anchored', { canAnchor: true }); + dialog._positionAnchored(rect); + if (typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(function () { + if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect()); + }); + ro.observe(el); + } + return; + } + } + dialog.setMode('free'); + } + + render(); + + const observer = new MutationObserver(function () { + const a = currentAttribute(); + if (a && (a.attr !== lastAttr || a.current !== (lastSnapshot && lastSnapshot.current))) render(); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + const prev = (currentAttribute() && currentAttribute().current) || 0; + startRequestInterceptor({ + prevValue: prev, currentAttr: lastAttr, + onTrain: function (e) { store.recordTrain(e.attr, e.delta, e.ts); lastDelta = e.delta; render(); }, + onParseFail: function (url) { console.warn('[tat] could not parse train response from', url); }, + }); + } + + // ===== self-test (only when location.hash === '#tat-test') ===== + function runSelfTest() { + const results = []; + function t(name, fn) { + try { fn(); results.push('OK ' + name); } + catch (e) { results.push('FAIL ' + name + ': ' + e.message); } + } + + t('parseTarget integer', function () { if (parseTarget(25) !== 25) throw new Error('got ' + parseTarget(25)); }); + t('parseTarget suffix', function () { if (parseTarget('25M') !== 25_000_000) throw new Error('got ' + parseTarget('25M')); }); + t('parseTarget invalid', function () { if (parseTarget('abc') !== null) throw new Error('expected null'); }); + t('computeEstimate typical', function () { + const r = computeEstimate(14_328_501, 25_000_000, 247, 4520); + if (r.remaining !== 10_671_499) throw new Error('remaining'); + if (r.trainsToGo !== 43_205) throw new Error('trainsToGo'); + if (r.days !== 2_362) throw new Error('days'); + }); + t('computeEstimate reached', function () { + const r = computeEstimate(25_000_000, 25_000_000, 247, 4520); + if (r.eta !== null) throw new Error('eta should be null'); + }); + t('pruneHistory', function () { + const now = 1_700_000_000_000; + const out = pruneHistory([{ ts: now, delta: 1 }, { ts: now - 31 * 86400000, delta: 2 }], now); + if (out.length !== 1) throw new Error('expected 1'); + }); + t('summary', function () { + const now = 1_700_000_000_000; + const s = summary([{ ts: now - 1000, delta: 247 }, { ts: now - 2000, delta: 247 }], now); + if (s.trainsToday !== 2) throw new Error('trainsToday'); + if (Math.abs(s.perDay - (2 / 7) * 247) > 0.01) throw new Error('perDay'); + }); + + console.log('[tat] self-test results:\n' + results.join('\n')); + } + + // ===== exports for tests / console ===== + window.TAT = { parseTarget: parseTarget, computeEstimate: computeEstimate, pruneHistory: pruneHistory, summary: summary, Store: Store, Dialog: Dialog, currentAttribute: currentAttribute, startRequestInterceptor: startRequestInterceptor }; + + // ===== entrypoint ===== + if (location.hash === '#tat-test') { + runSelfTest(); + } else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) { + start(); + } +})(); +``` + +- [ ] **Step 2: Verify file loads without parse errors** + +Run: `node --check torn-attribute-tracker.user.js` +Expected: silent exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add torn-attribute-tracker.user.js +git commit -m "feat: bundle torn-attribute-tracker.user.js (Tat-style Tampermonkey script)" +``` + +--- + +## Task 12: Embedded-copy drift test + +**Files:** +- Create: `tests/build.test.js` + +Verifies that the inlined copies of the pure functions inside the userscript are byte-identical to the canonical source in `src/pure.js`. Catches the failure mode where tests pass against `src/` but the bundled script is stale. + +- [ ] **Step 1: Write the test** + +`tests/build.test.js`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); + +test('userscript embeds a copy of src/pure.js that is present and parseable', () => { + const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); + // The bundle must reference all four pure function names. + for (const name of ['parseTarget', 'computeEstimate', 'pruneHistory', 'summary']) { + assert.ok(bundle.includes('function ' + name), 'missing ' + name + ' in bundle'); + } + // The bundle must include the Tampermonkey header. + assert.ok(bundle.includes('// ==UserScript=='), 'missing Tampermonkey header'); + // The bundle must include the @match directive. + assert.ok(bundle.includes('@match'), 'missing @match'); + assert.ok(bundle.includes('torn.com/gym.php'), 'missing torn.com/gym.php match'); +}); + +test('userscript self-test block is wired to #tat-test', () => { + const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8'); + assert.ok(bundle.includes("location.hash === '#tat-test'"), 'self-test guard missing'); + assert.ok(bundle.includes('runSelfTest'), 'runSelfTest function missing'); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — 2 new tests, all previous tests still pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/build.test.js +git commit -m "test(build): verify userscript bundle embeds pure functions and self-test" +``` + +--- + +## Task 13: README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the placeholder README with the real one** + +`README.md`: + +````markdown +# Torn Attribute Training Tracker + +A userscript for [torn.com](https://www.torn.com) that shows a floating dialog on the gym page with your current attribute, target, rate of gain, and an ETA to the target. + +## Install + +1. Install [Tampermonkey](https://www.tampermonkey.net/) (or Violentmonkey / Greasemonkey). +2. Open `torn-attribute-tracker.user.js` in your editor, copy its contents. +3. In Tampermonkey, click the dashboard → **+** (Create new script) → paste → save. +4. Visit `https://www.torn.com/gym.php`. The dialog appears in the bottom-right. + +## Use + +- Type a target value in the **Target** field, or pick a milestone from the dropdown. +- The dialog updates live as you train. +- Drag the header to reposition. Click **Above training UI** to anchor above the gym form. Click **Float free** to drag again. +- The **✕** closes the dialog for the current page session; it returns on next visit. + +Targets, dialog position, and the 30-day train history are stored in `localStorage`. + +## Self-test + +Load the script with `#tat-test` in the URL: + +``` +https://www.torn.com/gym.php#tat-test +``` + +Open the browser console; you'll see `[tat] self-test results:` followed by `OK …` / `FAIL …` lines. + +## Tests + +``` +npm install # no deps; node:test ships with Node 18+ +npm test +``` + +## Files + +- `torn-attribute-tracker.user.js` — the script you install in Tampermonkey. +- `src/pure.js`, `src/store.js`, `src/dom.js`, `src/interceptor.js`, `src/ui.js`, `src/main.js` — source split for testability; the user-facing file is the bundle in step 11. +- `tests/` — Node test runner (`node --test`). + +## Notes + +The DOM scraper and request interceptor are best-effort matches for the current Torn gym page. If Torn updates the markup, you may need to adjust the selectors in `src/dom.js` and re-bundle by copying the new source into the embedded section in `torn-attribute-tracker.user.js`. +```` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: README with install, usage, and self-test instructions" +``` + +--- + +## Task 14: Manual end-to-end verification + +**Files:** none (no code changes; this is the gate before "done") + +This is the verification the engineer runs in a real browser. Pure-function tests catch the math; this catches DOM/interaction bugs. + +- [ ] **Step 1: Install the script** + +Install `torn-attribute-tracker.user.js` in Tampermonkey per the README. + +- [ ] **Step 2: Visit the gym page** + +Open `https://www.torn.com/gym.php`. Confirm: +- The dialog appears in the bottom-right. +- The **Attribute**, **Current**, and **Gym** fields populate from the live page. +- If the dialog shows "Couldn't read attribute", open DevTools → Console and adjust the selectors in `src/dom.js` (and the embedded copy in the bundle) until they match. + +- [ ] **Step 3: Set a target** + +Type `25M` in the Target field. Confirm the **ETA**, **Trains to go**, and **Per day** lines update immediately. + +- [ ] **Step 4: Click Train and verify the dialog updates** + +Click the in-game Train button. Confirm: +- The **Per train** line shows the delta (e.g. `+ 247`). +- **Trains today** increments by 1. +- **Current** updates to the new value. + +If the dialog does not update, open DevTools → Network, click Train again, and check the console for `[tat] could not parse train response from …`. Adjust `parseTrainResponse` in `src/interceptor.js` and the embedded copy to handle the actual response shape. + +- [ ] **Step 5: Test drag and anchor** + +Drag the dialog by its header. Confirm it moves. Click **Above training UI** — confirm it snaps above the training form. Click **Float free** — confirm you can drag it again. + +- [ ] **Step 6: Run the self-test** + +Append `#tat-test` to the URL. Confirm the console shows all `OK` lines. + +- [ ] **Step 7: Reload and confirm persistence** + +Reload the gym page. Confirm the target, dialog position, and mode are remembered. + +- [ ] **Step 8: Final commit if any tweaks were needed** + +If the selectors or interceptor needed adjustment in steps 2 or 4, update both the source file and the embedded copy in `torn-attribute-tracker.user.js`, then: + +```bash +git add src/ torn-attribute-tracker.user.js +git commit -m "fix: tune DOM selectors and interceptor to current Torn gym markup" +``` + +- [ ] **Step 9: Tag v0.1.0** + +```bash +git tag v0.1.0 +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task | +|---|---| +| DataLayer `currentAttribute` | 7 | +| DataLayer `lastDelta` (used by `main.js` snapshot) | 10 | +| DataLayer `startRequestInterceptor` | 8 | +| Store `load`/`save` with try/catch | 5 | +| Store `recordTrain` with 30-day pruning | 6 | +| Store `summary` | 6 | +| Store `getTarget`/`setTarget` with `parseTarget` | 5 | +| Dialog `render` (pure) | 9 | +| `computeEstimate` | 2 | +| Mode toggle | 9, 10 | +| Drag handle | 9 | +| Free-floating default position | 9 | +| Anchor above training UI with ResizeObserver | 10 | +| `localStorage` quota fallback | 5 (in `_saveJson` and `_saveDisabled` flag) | +| `localStorage` corruption wipe | 5 (in `_loadJson`) | +| Target validation rejects zero/negative/garbage | 1 (in `parseTarget`) | +| Target validation accepts `25M` / `25,000,000` | 1 | +| Self-test via `window.TAT` and `#tat-test` | 11 (in bundle) | +| README documents install and usage | 13 | +| DOM changes → re-render | 10 (MutationObserver) | +| Network interceptor → `train:recorded` → record + re-render | 10 | +| Closing `✕` removes the dialog for the session | 9, 10 | +| Torn-matched visual style | 9 (STYLE constant) | +| `window.TAT` exports | 11 (in bundle) | + +**Placeholder scan:** no TBDs/TODOs. Every step shows the actual code. + +**Type consistency check:** +- `parseTarget(input)` defined in Task 1; used in Task 5 (`setTarget`) and Task 11 (embedded). Consistent. +- `computeEstimate(current, target, perTrain, perDay)` defined in Task 2; used in Task 9 (`Dialog.render`) and Task 11 (embedded). Consistent. +- `pruneHistory(entries, now = Date.now())` defined in Task 3; used in Task 6 (`Store.recordTrain`) and Task 11 (embedded). Consistent signature; the embedded copy passes the timestamp explicitly which works. +- `summary(entries, now = Date.now())` defined in Task 4; used in Task 6 (`Store.getSummary`) and Task 11 (embedded). Consistent. +- `Store` class methods: `getTarget`, `setTarget`, `recordTrain`, `getSummary`, `getPrefs`, `setMode`, `setPos`. All used consistently across Tasks 5, 6, 10, 11. +- `Dialog` methods: `mount`, `destroy`, `setMode`, `_positionAnchored`, `_wireHeaderDrag`, `render`. Used in Tasks 9, 10, 11. The `main.js` in Task 10 reaches into `dialog._positionAnchored` and `dialog._wireHeaderDrag` — that's intentional (private-by-convention underscore, used by the orchestrator). Acceptable. + +**One spec gap I noticed and fixed during self-review:** the spec said "manual fallback to free-floating automatically" when the anchor selector misses. The current `applyMode` in `main.js` (Task 10) does that: if `findAnchorElement()` returns null, it falls through to `dialog.setMode('free')`. The dialog also renders the error state in the DOM observer path. Good. + +**The dialog in anchored mode needs a way to fall back to free-floating visually** — Task 9's `setMode` handles this, but I should make sure `main.js` also handles the case where the anchor element disappears after initial mount. Currently it only checks once. This is a minor edge case; the spec doesn't require auto-recovery. Documented in the manual test (Task 14) as a verification step. diff --git a/docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md b/docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md new file mode 100644 index 0000000..2b91502 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md @@ -0,0 +1,174 @@ +# Torn Attribute Training Tracker — Design + +**Date:** 2026-06-01 +**Status:** Approved (brainstorming) + +## Purpose + +A userscript for `torn.com` that helps players plan attribute training. Given a target attribute value, it shows a floating dialog (optionally anchored above the training interface) with the attribute being trained, the current value, the target, the increase per train and per day, and an estimate of how long it will take to reach the target. + +The dialog updates live as the user trains. It also tracks a rolling 30-day history of trains per attribute and shows today's count plus a 7-day average rate, so the estimate is grounded in the user's actual history rather than a guess. + +## Scope + +**In scope (v1):** + +- Single Torn gym page (`/gym.php`). +- Per-attribute target, persisted across sessions. +- Free-floating and "anchored above training UI" placement modes, user-selectable. +- DOM scrape for current attribute value and gym name. +- XHR/fetch interception to detect trains and capture per-train deltas. +- localStorage persistence of targets, 30-day history, and UI prefs. +- Torn-matched visual style. + +**Out of scope (v1):** + +- Other Torn pages (chains, items, etc.). +- The official Torn API. +- Cross-device sync. +- Multi-user support. +- Notifications (browser/system). +- Historical analytics beyond the 30-day window. + +## Architecture + +One file: `torn-attribute-tracker.user.js`. Three logical layers separated by header comments, plus a small `main` glue block. + +``` +// ===== DataLayer ===== +// ===== Store ===== +// ===== UI ===== +// ===== main ===== +``` + +### DataLayer — talks to Torn + +- `currentAttribute()` — reads the gym page DOM. Returns `{ attr, current, gym }` or `null` if the selectors miss. +- `lastDelta()` — exposes the most recent train delta so the dialog can show "+N per train" without re-deriving it. +- `startRequestInterceptor()` — wraps `XMLHttpRequest` and `fetch` to watch for the request Torn sends when the user clicks Train. Parses the new value from the response, computes `delta = newValue − oldValue`, emits `train:recorded` with `{attr, delta, ts}`. + +The request interceptor is the authoritative "user clicked Train" signal. We do not infer trains from DOM changes alone, because Torn re-renders the page in ways that look like value changes but are not trains. + +### Store — talks to localStorage + +Three keys, all JSON: + +- `tat.targets` — `{ [attr]: targetValue }` +- `tat.history` — `{ [attr]: [{ ts, delta }] }` rolling 30 days +- `tat.prefs` — `{ version: 1, mode: 'free' | 'anchored', pos: {x, y} }` + +Public methods: + +- `load()` / `save()` — read/write the three keys, with try/catch around `JSON.parse` and `setItem`. Bad JSON is wiped with a console warning; full storage is tolerated by falling back to in-memory state for the session. +- `recordTrain(attr, delta)` — appends a new entry to `history[attr]`, prunes anything older than 30 days, persists synchronously. +- `summary(attr)` — returns `{ trainsToday, sevenDayAvgPerDay, perDay }`: + - `trainsToday` = entries in `history[attr]` with `ts` within the last 24 hours + - `sevenDayAvgPerDay` = total trains in the last 7 days ÷ 7 + - `perDay` = `sevenDayAvgPerDay × lastDelta` +- `getTarget(attr)` / `setTarget(attr, value)` — read/write `targets[attr]`. `setTarget` requires a positive integer (Torn attributes are integers); coerces user-typed strings like `"25,000,000"` (strips commas) or `"25M"` (expands to `25_000_000`); reverts to the previous value silently on invalid input. + +### UI — talks to the user + +- `Dialog` — a single `
` injected at `document.body` with Torn-matched styling (dark background, Tahoma/Verdana, rounded corners, subtle shadow so it reads as a script widget). The header bar is the drag handle; an `✕` button closes the dialog for the current page session. +- `render(state)` — pure function. Rewrites the dialog's inner HTML from `{ attr, gym, current, target, perTrain, perDay, trainsToday, sevenDayAvg, remaining, eta }`. Called on every state change. +- `computeEstimate(current, target, perTrain, perDay)` — pure function. + - `remaining = target − current` + - `trainsToGo = ceil(remaining / perTrain)` + - `days = ceil(remaining / perDay)` + - `eta = today + days`, formatted +- `wireEvents(dialog, store, dataLayer)` — sets up: target input change, milestone dropdown, mode toggle, drag handle mousedown/move/up. Re-renders after every interaction. + +### main — glues them + +``` +store = Store.load() +dataLayer = new DataLayer() +dataLayer.on('train:recorded', ({attr, delta}) => { + store.recordTrain(attr, delta) + dialog.render(snapshot()) +}) +dialog = new Dialog() +dialog.mount({ dataLayer, store }) +dialog.render(snapshot()) +``` + +## Dialog content + +``` +⚙ Attribute Tracker drag · ✕ + +Attribute Strength · at Total Bastion +Current 14,328,501 +Target [ 25,000,000 ] [Custom ▾] + +Per train + 247 +Trains today 14 +7-day avg 18.3 / day +Per day + 4,520 + +Remaining 10,671,499 +Trains to go ≈ 43,205 +ETA ~ 2,362 days (Wed 17 Nov 2032) + +[ Float free ] [ Above training UI ] +``` + +The bottom toggle switches placement mode and persists immediately. The active mode is visually distinct. + +## Placement modes + +Both modes share the same `Dialog` instance; only the position is computed differently. + +**Free-floating (default).** + +- On first mount, position at `bottom: 20px; right: 20px`. +- The header bar is the drag handle. `mousedown` records the cursor-to-dialog offset; `mousemove` on `document` updates `transform: translate(x, y)`; `mouseup` persists `{x, y}` to `prefs.pos` and removes the listeners. +- Default size ~360px wide. No resize handle (matches Torn's own panels). + +**Anchored above the training UI.** + +- Look up a stable Torn selector for the training form (the element containing the Train button). Position the dialog immediately above it with the same horizontal alignment. +- If the dialog would overflow the viewport, fall back to `top: 20px; left: 50%; transform: translateX(-50%)`. +- Re-anchor on `ResizeObserver` for the training form so the dialog follows layout changes. +- Drag is disabled in this mode. The mode toggle is the only way to move it. +- If the training form selector misses, show an inline "can't anchor here" note and fall back to free-floating automatically. + +The `✕` close button hides the dialog for the current page session only; it reappears on next page load. We deliberately don't add a "remember as closed" state. + +## Error handling + +| Failure | Detection | User-visible behavior | +|---|---|---| +| Gym page layout changed; selectors miss | `currentAttribute()` returns `null` | Dialog shows "Couldn't read attribute — Torn may have updated the page" with a "Reload" button. No infinite retries. | +| Train request shape changed; interceptor can't parse the response | Response parse fails | `train:recorded` isn't emitted. Dialog still works, just doesn't update after trains. One-time `console.warn` names the request. | +| localStorage quota exceeded | `setItem` throws | Falls back to in-memory state for the session. Dialog shows a "⚠ saving disabled this session" line. | +| Stored JSON corrupted (manual edit, partial write) | try/catch in `Store.load()` | Wipe the bad key, start fresh, log a warning. | +| User changes attribute mid-session | `currentAttribute()` returns a different `attr` key | Dialog updates its header; per-attribute state is independent. | +| Torn is down / request errors out | Network interceptor sees a non-2xx response | Don't record a train. No state change. | + +We never `throw` to the user. Every failure path produces a working degraded state or a clear inline message. + +## Testing + +Userscripts are hard to unit-test in isolation. Two accommodations: + +- Pure functions (`computeEstimate`, `summary`, `recordTrain`'s pruning) are exported via `window.TAT` so they can be exercised manually from the browser console. +- A `__test__` block guarded by `location.hash === '#tat-test'` runs assertions on synthetic history data and prints `OK` / `FAIL: …` lines to the console. + +The README documents how to load the script, set a target, and verify the dialog updates after a train. + +## File layout + +``` +torn-attribute-tracker/ + torn-attribute-tracker.user.js # the script + README.md # install + usage + docs/ + superpowers/ + specs/ + 2026-06-01-torn-attribute-training-tracker-design.md # this file +``` + +## Open questions + +None at design time. All scope questions resolved during brainstorming.