Native Torn-styled education progress, personal course planner, and cached active education perk tracker.
// ==UserScript==
// @name Torn Education Tracker
// @namespace https://www.torn.com/
// @version 1.3.0
// @description Native Torn-styled education progress, personal course planner, and cached active education perk tracker.
// @author ST4TIC / Jade
// @license All Rights Reserved
// @match https://www.torn.com/page.php?sid=education*
// @match https://www.torn.com/page.php*sid=education*
// @match https://www.torn.com/index.php*
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @run-at document-idle
// ==/UserScript==
/*
* Copyright © 2026 ST4TIC.
* All Rights Reserved.
*
* This software may not be copied, modified, redistributed,
* republished, sublicensed, or used to create derivative works
* without the express written permission of the copyright owner.
*/
(() => {
'use strict';
/* =========================================================
CORE CONFIGURATION
========================================================= */
const TRACKER_ID = 'tet-education-tracker';
const STYLE_ID = 'tet-education-tracker-styles';
const MODAL_ID = 'tet-education-plan-modal';
const API_DIALOG_ID = 'tet-api-dialog';
const API_BASE = 'https://api.torn.com/v2';
const STORAGE = {
apiKey: 'tet_education_api_key',
plan: 'tet_education_plan_v1',
collapsed: 'tet_education_collapsed',
catalogueCache: 'tet_education_catalogue_cache_v1',
userCache: 'tet_education_user_cache_v1',
perkCache: 'tet_education_home_perks_cache_v1'
};
const CACHE_DURATION = 10 * 60 * 1000;
const REQUEST_TIMEOUT = 30000;
const state = {
loading: false,
error: '',
catalogue: [],
completedCourseIds: new Set(),
currentCourse: null,
plan: loadPlan(),
countdownTimer: null,
courseTimestampObserver: null,
courseTimestampPollTimer: null,
courseTimestampStopTimer: null,
outcomeHarvestRunning: false,
lastUrl: window.location.href
};
/* =========================================================
GENERAL UTILITIES
========================================================= */
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>"']/g,
character => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[character]
);
}
function number(value) {
const parsed = Number(
String(value ?? 0).replace(/,/g, '')
);
return Number.isFinite(parsed)
? parsed
: 0;
}
function normaliseText(value) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function formatNumber(value) {
return number(value).toLocaleString('en-AU');
}
function formatDateTime(timestamp) {
if (!timestamp) {
return '—';
}
return new Date(
number(timestamp) * 1000
).toLocaleString(
'en-AU',
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}
);
}
function formatDuration(totalSeconds) {
const seconds = Math.max(
0,
Math.floor(number(totalSeconds))
);
if (seconds <= 0) {
return 'Complete';
}
const days = Math.floor(
seconds / 86400
);
const hours = Math.floor(
(seconds % 86400) / 3600
);
const minutes = Math.floor(
(seconds % 3600) / 60
);
const remainingSeconds =
seconds % 60;
const parts = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (
hours > 0 ||
days > 0
) {
parts.push(`${hours}h`);
}
parts.push(`${minutes}m`);
parts.push(`${remainingSeconds}s`);
return parts.join(' ');
}
function formatPlanDuration(totalSeconds) {
const seconds = Math.max(
0,
Math.floor(number(totalSeconds))
);
if (seconds <= 0) {
return '0h 0m';
}
const days = Math.floor(
seconds / 86400
);
const hours = Math.floor(
(seconds % 86400) / 3600
);
const minutes = Math.floor(
(seconds % 3600) / 60
);
const parts = [];
if (days > 0) {
parts.push(`${days}d`);
}
parts.push(`${hours}h`);
parts.push(`${minutes}m`);
return parts.join(' ');
}
function isEducationPage() {
const url =
new URL(window.location.href);
return (
url.pathname.endsWith('/page.php') &&
url.searchParams.get('sid') === 'education'
);
}
function isTornHomePage() {
const url =
new URL(window.location.href);
return (
(
url.pathname === '/' ||
url.pathname.endsWith('/index.php')
) &&
!url.searchParams.get('sid')
);
}
function loadPerkCache() {
try {
const saved = JSON.parse(
localStorage.getItem(
STORAGE.perkCache
) || 'null'
);
if (
!saved ||
!Array.isArray(saved.perks)
) {
return {
savedAt: 0,
perks: []
};
}
return {
savedAt:
number(saved.savedAt),
perks:
saved.perks
.map(normaliseText)
.filter(Boolean)
};
} catch {
return {
savedAt: 0,
perks: []
};
}
}
function savePerkCache(perks) {
const cleanPerks = [
...new Set(
perks
.map(normaliseText)
.filter(Boolean)
)
];
if (!cleanPerks.length) {
return;
}
try {
localStorage.setItem(
STORAGE.perkCache,
JSON.stringify({
savedAt: Date.now(),
perks: cleanPerks
})
);
} catch {
// Perk cache failure does not stop Torn.
}
}
function readEducationPerksFromHomeDom() {
const category =
document.querySelector(
'.perk-category-container[data-category="Education"]'
);
if (!category) {
return [];
}
const list =
category.querySelector(
'ul.perk-category-perks-list'
);
if (!list) {
return [];
}
return [
...list.querySelectorAll(
':scope > li'
)
]
.map(item => {
const wrapper =
item.querySelector(
'.perk-description-wrapper'
);
if (!wrapper) {
return '';
}
const directSpans = [
...wrapper.querySelectorAll(
':scope > span'
)
];
return normaliseText(
directSpans[1]?.textContent ||
''
);
})
.filter(Boolean);
}
function captureEducationPerksFromHome() {
if (!isTornHomePage()) {
return;
}
let settled = false;
const tryCapture = () => {
const perks =
readEducationPerksFromHomeDom();
if (!perks.length) {
return false;
}
savePerkCache(perks);
settled = true;
observer.disconnect();
clearInterval(pollTimer);
clearTimeout(stopTimer);
return true;
};
const observer =
new MutationObserver(
() => tryCapture()
);
observer.observe(
document.documentElement,
{
childList: true,
subtree: true
}
);
const pollTimer =
window.setInterval(
() => tryCapture(),
500
);
const stopTimer =
window.setTimeout(
() => {
if (!settled) {
observer.disconnect();
clearInterval(pollTimer);
}
},
30000
);
tryCapture();
}
function buildActivePerks() {
return loadPerkCache()
.perks
.map(perk => ({
type: 'unique',
title: perk,
amount: null,
courses: []
}));
}
/* =========================================================
STORAGE
========================================================= */
function getApiKey() {
return (
localStorage.getItem(
STORAGE.apiKey
) || ''
).trim();
}
function saveApiKey(apiKey) {
localStorage.setItem(
STORAGE.apiKey,
String(apiKey || '').trim()
);
}
function loadPlan() {
try {
const saved = JSON.parse(
localStorage.getItem(
STORAGE.plan
) || '[]'
);
return Array.isArray(saved)
? saved
: [];
} catch {
return [];
}
}
function savePlan() {
localStorage.setItem(
STORAGE.plan,
JSON.stringify(state.plan)
);
}
function readCache(storageKey) {
try {
const cached = JSON.parse(
localStorage.getItem(
storageKey
) || 'null'
);
if (
!cached ||
!cached.savedAt ||
!cached.data
) {
return null;
}
if (
Date.now() - cached.savedAt >
CACHE_DURATION
) {
return null;
}
return cached.data;
} catch {
return null;
}
}
function writeCache(
storageKey,
data
) {
try {
localStorage.setItem(
storageKey,
JSON.stringify({
savedAt: Date.now(),
data
})
);
} catch {
// Cache failure does not stop tracker.
}
}
/* =========================================================
TORN API
========================================================= */
function apiRequest(path) {
const apiKey =
getApiKey();
if (!apiKey) {
return Promise.reject(
new Error(
'A Torn API key is required to load education progress.'
)
);
}
const separator =
path.includes('?')
? '&'
: '?';
const url =
`${API_BASE}${path}${separator}` +
`key=${encodeURIComponent(apiKey)}`;
return new Promise(
(
resolve,
reject
) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: REQUEST_TIMEOUT,
onload(response) {
let data;
try {
data = JSON.parse(
response.responseText
);
} catch {
reject(
new Error(
`Torn returned an invalid response (${response.status}).`
)
);
return;
}
if (data?.error) {
reject(
new Error(
data.error.error ||
data.error.message ||
'Torn API request failed.'
)
);
return;
}
if (
response.status < 200 ||
response.status >= 300
) {
reject(
new Error(
`Torn API request failed (${response.status}).`
)
);
return;
}
resolve(data);
},
onerror() {
reject(
new Error(
'The Torn API request failed.'
)
);
},
ontimeout() {
reject(
new Error(
'The Torn API request timed out.'
)
);
}
});
}
);
}
/* =========================================================
CATALOGUE NORMALISATION
========================================================= */
function normaliseCatalogueResponse(data) {
const source =
data.education ||
data.courses ||
data.education_courses ||
data;
const categories = [];
if (Array.isArray(source)) {
for (const item of source) {
if (
item?.courses &&
Array.isArray(item.courses)
) {
categories.push(item);
}
}
} else if (
source &&
typeof source === 'object'
) {
for (
const [key, value]
of Object.entries(source)
) {
if (Array.isArray(value)) {
categories.push({
id: key,
name: key,
courses: value
});
continue;
}
if (
value &&
typeof value === 'object' &&
Array.isArray(value.courses)
) {
categories.push({
id:
value.id ??
value.category_id ??
key,
name:
value.name ??
value.category_name ??
key,
...value
});
}
}
}
const courses = [];
for (
const category
of categories
) {
const categoryName =
category.name ||
category.category_name ||
category.title ||
'Unknown';
const rawCourses =
category.courses ||
category.education ||
[];
for (
const rawCourse
of rawCourses
) {
const id = String(
rawCourse.id ??
rawCourse.course_id ??
rawCourse.education_id ??
''
);
const name =
rawCourse.name ||
rawCourse.course_name ||
rawCourse.title ||
'';
if (
!id ||
!name
) {
continue;
}
courses.push({
id,
name:
normaliseText(name),
category:
normaliseText(
categoryName
),
categoryId:
String(
category.id ??
category.category_id ??
''
),
duration:
number(
rawCourse.duration ??
rawCourse.time ??
rawCourse.course_time ??
0
),
prerequisites:
rawCourse.prerequisites ||
rawCourse.prerequisite ||
[],
raw:
rawCourse
});
}
}
if (!courses.length) {
const flatCandidates = [
data.education,
data.courses,
data.education_courses
];
for (
const candidate
of flatCandidates
) {
if (
!Array.isArray(candidate)
) {
continue;
}
for (
const rawCourse
of candidate
) {
const id = String(
rawCourse.id ??
rawCourse.course_id ??
rawCourse.education_id ??
''
);
const name =
rawCourse.name ||
rawCourse.course_name ||
rawCourse.title ||
'';
if (
!id ||
!name
) {
continue;
}
courses.push({
id,
name:
normaliseText(name),
category:
normaliseText(
rawCourse.category ||
rawCourse.category_name ||
'Unknown'
),
categoryId:
String(
rawCourse.category_id ||
''
),
duration:
number(
rawCourse.duration ??
rawCourse.time ??
rawCourse.course_time ??
0
),
prerequisites:
rawCourse.prerequisites ||
rawCourse.prerequisite ||
[],
raw:
rawCourse
});
}
}
}
return courses.sort(
(
a,
b
) => {
const categoryCompare =
a.category.localeCompare(
b.category
);
if (
categoryCompare !== 0
) {
return categoryCompare;
}
return a.name.localeCompare(
b.name
);
}
);
}
/* =========================================================
USER EDUCATION NORMALISATION
========================================================= */
function recursivelyCollectCompletedIds(
value,
completedIds
) {
if (Array.isArray(value)) {
for (
const item
of value
) {
if (
typeof item === 'number' ||
typeof item === 'string'
) {
completedIds.add(
String(item)
);
continue;
}
recursivelyCollectCompletedIds(
item,
completedIds
);
}
return;
}
if (
!value ||
typeof value !== 'object'
) {
return;
}
const status =
String(
value.status ||
value.state ||
value.course_status ||
''
).toLowerCase();
const completed =
value.completed === true ||
value.complete === true ||
status === 'completed' ||
status === 'complete' ||
status === 'finished';
const id =
value.id ??
value.course_id ??
value.education_id;
if (
completed &&
id !== undefined &&
id !== null
) {
completedIds.add(
String(id)
);
}
for (
const [key, child]
of Object.entries(value)
) {
const keyLower =
key.toLowerCase();
if (
keyLower.includes('complete') &&
Array.isArray(child)
) {
for (
const childId
of child
) {
if (
typeof childId === 'string' ||
typeof childId === 'number'
) {
completedIds.add(
String(childId)
);
}
}
}
recursivelyCollectCompletedIds(
child,
completedIds
);
}
}
function findCurrentCourseObject(data) {
const directCandidates = [
data.current,
data.current_course,
data.education?.current,
data.education?.current_course,
data.course,
data.education
];
for (
const candidate
of directCandidates
) {
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate)
) {
continue;
}
const id =
candidate.id ??
candidate.course_id ??
candidate.education_id;
const name =
candidate.name ||
candidate.course_name ||
candidate.title;
const endTimestamp =
number(
candidate.end ??
candidate.ends ??
candidate.end_time ??
candidate.completion_time ??
candidate.complete_at ??
candidate.finish_time ??
candidate.timestamp
);
if (
id !== undefined ||
name ||
endTimestamp
) {
return {
id:
id !== undefined &&
id !== null
? String(id)
: '',
name:
normaliseText(
name || ''
),
category:
normaliseText(
candidate.category ||
candidate.category_name ||
''
),
endTimestamp,
raw:
candidate
};
}
}
return null;
}
function normaliseUserEducationResponse(data) {
const completedIds =
new Set();
recursivelyCollectCompletedIds(
data,
completedIds
);
return {
completedIds,
currentCourse:
findCurrentCourseObject(
data
)
};
}
/* =========================================================
EDUCATION DOM
========================================================= */
function getEducationRoot() {
return document.querySelector(
'#education-root'
);
}
function readCurrentCourseFromDom() {
const pageText =
normaliseText(
document.body?.innerText ||
document.body?.textContent ||
''
);
const courseMatch =
pageText.match(
/You are taking the\s+(.+?)\s+education course/i
);
const completionMatch =
pageText.match(
/This course will be completed in\s+(.+?)\s+\((\d{2}\.\d{2}\.\d{4}\s+\d{2}:\d{2}:\d{2})\)/i
);
let endTimestamp = 0;
if (
completionMatch?.[2]
) {
const timestampText =
completionMatch[2];
const timestampMatch =
timestampText.match(
/^(\d{2})\.(\d{2})\.(\d{4})\s+(\d{2}):(\d{2}):(\d{2})$/
);
if (timestampMatch) {
const [
,
day,
month,
year,
hour,
minute,
second
] = timestampMatch.map(Number);
const completionDate =
new Date(
year,
month - 1,
day,
hour,
minute,
second
);
if (
Number.isFinite(
completionDate.getTime()
)
) {
endTimestamp =
Math.floor(
completionDate.getTime() /
1000
);
}
}
}
if (
!courseMatch &&
!completionMatch
) {
return null;
}
return {
id: '',
name:
normaliseText(
courseMatch?.[1] ||
''
),
category: '',
endTimestamp,
rawRemainingText:
normaliseText(
completionMatch?.[1] ||
''
)
};
}
function enrichCurrentCourse(
currentCourse
) {
if (!currentCourse) {
return null;
}
let catalogueCourse = null;
if (
currentCourse.id
) {
catalogueCourse =
state.catalogue.find(
course =>
course.id ===
String(
currentCourse.id
)
);
}
if (
!catalogueCourse &&
currentCourse.name
) {
catalogueCourse =
state.catalogue.find(
course =>
course.name
.toLowerCase() ===
currentCourse.name
.toLowerCase()
);
}
if (!catalogueCourse) {
return currentCourse;
}
return {
...currentCourse,
id:
currentCourse.id ||
catalogueCourse.id,
name:
currentCourse.name ||
catalogueCourse.name,
category:
currentCourse.category ||
catalogueCourse.category,
duration:
catalogueCourse.duration,
raw:
catalogueCourse.raw
};
}
function stopCurrentCourseTimestampWatch() {
if (
state.courseTimestampObserver
) {
state.courseTimestampObserver
.disconnect();
state.courseTimestampObserver =
null;
}
if (
state.courseTimestampPollTimer
) {
clearInterval(
state.courseTimestampPollTimer
);
state.courseTimestampPollTimer =
null;
}
if (
state.courseTimestampStopTimer
) {
clearTimeout(
state.courseTimestampStopTimer
);
state.courseTimestampStopTimer =
null;
}
}
function captureCurrentCourseTimestamp() {
if (
!isEducationPage()
) {
stopCurrentCourseTimestampWatch();
return false;
}
const domCurrentCourse =
readCurrentCourseFromDom();
if (
!domCurrentCourse?.endTimestamp
) {
return false;
}
const previousEndTimestamp =
number(
state.currentCourse?.endTimestamp
);
const previousName =
normaliseText(
state.currentCourse?.name ||
''
);
state.currentCourse =
enrichCurrentCourse({
...(
state.currentCourse ||
{}
),
name:
domCurrentCourse.name ||
previousName,
endTimestamp:
domCurrentCourse.endTimestamp,
rawRemainingText:
domCurrentCourse.rawRemainingText
});
if (
previousEndTimestamp !==
domCurrentCourse.endTimestamp ||
(
!previousName &&
domCurrentCourse.name
)
) {
renderTracker();
}
stopCurrentCourseTimestampWatch();
return true;
}
function watchForCurrentCourseTimestamp() {
stopCurrentCourseTimestampWatch();
if (
captureCurrentCourseTimestamp()
) {
return;
}
state.courseTimestampObserver =
new MutationObserver(
() => {
captureCurrentCourseTimestamp();
}
);
state.courseTimestampObserver.observe(
document.body,
{
childList: true,
subtree: true,
characterData: true
}
);
state.courseTimestampPollTimer =
window.setInterval(
() => {
captureCurrentCourseTimestamp();
},
250
);
state.courseTimestampStopTimer =
window.setTimeout(
() => {
stopCurrentCourseTimestampWatch();
},
30000
);
}
/* =========================================================
CATEGORY PROGRESS
========================================================= */
function calculateCategoryProgress() {
const currentCourse =
state.currentCourse;
if (
!currentCourse ||
!currentCourse.category
) {
return {
category: 'Unknown',
total: 0,
completed: 0,
remaining: 0
};
}
const categoryCourses =
state.catalogue.filter(
course =>
course.category
.toLowerCase() ===
currentCourse.category
.toLowerCase()
);
const completed =
categoryCourses.filter(
course =>
state.completedCourseIds
.has(course.id)
).length;
return {
category:
currentCourse.category,
total:
categoryCourses.length,
completed,
remaining:
Math.max(
0,
categoryCourses.length -
completed
)
};
}
/* =========================================================
PLAN HELPERS
========================================================= */
function synchronisePlan() {
let changed = false;
state.plan =
state.plan.map(
item => {
const catalogueCourse =
state.catalogue.find(
course =>
course.id ===
String(item.id)
);
const isComplete =
state.completedCourseIds
.has(
String(item.id)
);
const updated = {
...item,
name:
catalogueCourse?.name ||
item.name,
category:
catalogueCourse?.category ||
item.category,
duration:
number(
catalogueCourse?.duration ??
item.duration ??
0
),
complete:
isComplete
};
if (
updated.name !== item.name ||
updated.category !== item.category ||
updated.duration !==
number(item.duration) ||
updated.complete !== item.complete
) {
changed = true;
}
return updated;
}
);
if (changed) {
savePlan();
}
}
function isCurrentPlanItem(item) {
if (
!state.currentCourse
) {
return false;
}
if (
state.currentCourse.id &&
String(item.id) ===
String(
state.currentCourse.id
)
) {
return true;
}
return (
item.name.toLowerCase() ===
state.currentCourse.name
.toLowerCase()
);
}
function getNextPlanItem() {
return (
state.plan.find(
item =>
!item.complete &&
!isCurrentPlanItem(item)
) ||
null
);
}
function getPlanTimeRemaining() {
return state.plan.reduce(
(
total,
item
) => {
if (
item.complete ||
isCurrentPlanItem(item)
) {
return total;
}
const catalogueCourse =
state.catalogue.find(
course =>
course.id ===
String(item.id)
);
return (
total +
number(
catalogueCourse?.duration ??
item.duration ??
0
)
);
},
0
);
}
function getRemainingPlanCount() {
return state.plan.filter(
item =>
!item.complete &&
!isCurrentPlanItem(item)
).length;
}
function addCourseToPlan(
courseId
) {
const course =
state.catalogue.find(
item =>
item.id ===
String(courseId)
);
if (!course) {
return;
}
if (
state.plan.some(
item =>
String(item.id) ===
course.id
)
) {
return;
}
state.plan.push({
id:
course.id,
name:
course.name,
category:
course.category,
duration:
course.duration,
complete:
state.completedCourseIds
.has(course.id)
});
savePlan();
renderTracker();
renderPlanModal();
}
function removeCourseFromPlan(
courseId
) {
state.plan =
state.plan.filter(
item =>
String(item.id) !==
String(courseId)
);
savePlan();
renderTracker();
renderPlanModal();
}
function movePlanItem(
courseId,
direction
) {
const currentIndex =
state.plan.findIndex(
item =>
String(item.id) ===
String(courseId)
);
if (
currentIndex < 0
) {
return;
}
const nextIndex =
currentIndex + direction;
if (
nextIndex < 0 ||
nextIndex >=
state.plan.length
) {
return;
}
const updated = [
...state.plan
];
[
updated[currentIndex],
updated[nextIndex]
] = [
updated[nextIndex],
updated[currentIndex]
];
state.plan = updated;
savePlan();
renderTracker();
renderPlanModal();
}
function clearCompletedPlanItems() {
state.plan =
state.plan.filter(
item =>
!item.complete
);
savePlan();
renderTracker();
renderPlanModal();
}
/* =========================================================
NATIVE TORN STYLES
========================================================= */
function injectStyles() {
if (
document.getElementById(
STYLE_ID
)
) {
return;
}
const style =
document.createElement(
'style'
);
style.id =
STYLE_ID;
style.textContent = `
#${TRACKER_ID},
#${TRACKER_ID} * {
box-sizing: border-box;
}
#${TRACKER_ID} {
--tet-bg: #202020;
--tet-bg-dark: #181818;
--tet-panel: #333333;
--tet-panel-dark: #2b2b2b;
--tet-panel-soft: #3a3a3a;
--tet-border: #444444;
--tet-border-soft: rgba(255, 255, 255, 0.08);
--tet-text: #f0f0f0;
--tet-muted: #aaa;
--tet-muted-dark: #777;
--tet-link: #74b9e8;
--tet-green: #70b33f;
--tet-green-bright: #9bd85f;
--tet-danger: #c96b6b;
--tet-radius: 5px;
width: 100%;
margin: 10px 0 50px;
color: var(--tet-text);
font-family:
Arial,
Helvetica,
sans-serif;
}
#${TRACKER_ID} button,
#${TRACKER_ID} input,
#${TRACKER_ID} select {
font: inherit;
}
.tet-shell {
overflow: hidden;
border: 1px solid #111;
border-radius: var(--tet-radius);
background: var(--tet-bg);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04);
}
.tet-header {
min-height: 52px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid #111;
background:
linear-gradient(
180deg,
#3c3c3c,
#303030
);
}
.tet-brand-mark {
width: 38px;
height: 38px;
flex: 0 0 38px;
display: grid;
place-items: center;
border: 1px solid #4b4b4b;
border-radius: 4px;
background:
linear-gradient(
180deg,
#484848,
#343434
);
color: #ddd;
font-size: 19px;
text-shadow:
0 1px 1px #000;
}
.tet-brand {
min-width: 0;
}
.tet-brand-title {
display: block;
margin: 0;
color: #eee;
font-size: 14px;
font-weight: 700;
line-height: normal;
}
.tet-brand p {
margin: 4px 0 0;
color: #999;
font-size: 10px;
letter-spacing: 0.8px;
}
.tet-spacer {
flex: 1;
}
.tet-header-button {
min-width: 34px;
height: 32px;
padding: 0 10px;
border: 1px solid #222;
border-radius: 4px;
background:
linear-gradient(
180deg,
#484848,
#333
);
color: #bbb;
cursor: pointer;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.08);
}
.tet-header-button:hover {
background:
linear-gradient(
180deg,
#555,
#3b3b3b
);
color: #fff;
}
.tet-body {
padding: 10px;
background: #202020;
}
.tet-error {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #7b4141;
border-radius: 4px;
background: #3d2828;
color: #efb0b0;
font-size: 12px;
}
.tet-loading {
min-height: 160px;
display: grid;
place-items: center;
padding: 30px;
color: #aaa;
text-align: center;
background: #202020;
}
.tet-loading strong {
display: block;
margin-bottom: 8px;
color: #eee;
font-size: 15px;
}
.tet-summary-grid {
display: grid;
grid-template-columns:
minmax(220px, 1.65fr)
repeat(4, minmax(125px, 1fr));
gap: 8px;
}
.tet-card {
min-width: 0;
padding: 12px;
border: 1px solid #222;
border-radius: 4px;
background:
linear-gradient(
180deg,
#383838,
#303030
);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.035);
}
.tet-card-label {
display: block;
min-height: 22px;
margin-bottom: 5px;
color: #bbb;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.6px;
line-height: 1.05;
}
.tet-current-course {
color: #f4f4f4;
font-size: 15px;
line-height: 1.3;
text-shadow:
0 1px 1px #000;
}
.tet-current-category {
display: inline-flex;
margin-top: 7px;
padding: 3px 7px;
border: 1px solid #555;
border-radius: 3px;
background: #292929;
color: #ccc;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.4px;
}
.tet-stat-value {
color: #f4f4f4;
font-size: 19px;
line-height: 1.1;
text-shadow:
0 1px 1px #000;
}
.tet-time-value {
color: #ddd;
}
.tet-plan-time-value {
color: var(--tet-green-bright);
}
.tet-stat-subtext {
display: block;
margin-top: 6px;
color: #888;
font-size: 9px;
line-height: 1.25;
}
.tet-course-progress {
margin-top: 8px;
height: 8px;
overflow: hidden;
border: 1px solid #111;
border-radius: 4px;
background:
linear-gradient(
180deg,
#171717,
#242424
);
box-shadow:
inset 0 1px 2px rgba(0,0,0,0.7);
}
.tet-course-progress-fill {
height: 100%;
background:
linear-gradient(
180deg,
#9ad94f,
#4d9d27
);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.3);
}
.tet-section {
margin-top: 10px;
overflow: hidden;
border: 1px solid #222;
border-radius: 4px;
background: #303030;
}
.tet-section-header {
min-height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-bottom: 1px solid #222;
background:
linear-gradient(
180deg,
#3e3e3e,
#333
);
}
.tet-section-header strong {
color: #eee;
font-size: 11px;
letter-spacing: 0.5px;
text-shadow:
0 1px 1px #000;
}
.tet-section-header span {
color: #888;
font-size: 9px;
}
.tet-primary-button,
.tet-secondary-button,
.tet-danger-button {
min-height: 30px;
padding: 0 11px;
border-radius: 4px;
cursor: pointer;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3px;
}
.tet-primary-button {
border: 1px solid #1d1d1d;
background:
linear-gradient(
180deg,
#555,
#393939
);
color: #eee;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.1);
}
.tet-primary-button:hover {
background:
linear-gradient(
180deg,
#626262,
#444
);
}
.tet-secondary-button {
border: 1px solid #222;
background:
linear-gradient(
180deg,
#484848,
#343434
);
color: #bbb;
}
.tet-secondary-button:hover {
color: #fff;
}
.tet-danger-button {
border: 1px solid #693b3b;
background:
linear-gradient(
180deg,
#5a3838,
#412929
);
color: #e1a0a0;
}
.tet-plan-list {
padding: 7px 10px;
background: #303030;
}
.tet-plan-row {
min-height: 44px;
display: grid;
grid-template-columns:
28px
minmax(0, 1fr)
auto;
align-items: center;
gap: 9px;
padding: 7px 9px;
border-bottom:
1px solid rgba(255,255,255,0.06);
}
.tet-plan-row:last-child {
border-bottom: 0;
}
.tet-plan-row:hover {
background:
rgba(255,255,255,0.025);
}
.tet-plan-row.tet-complete {
opacity: 0.55;
}
.tet-plan-row.tet-current {
background:
linear-gradient(
90deg,
rgba(112,179,63,0.12),
transparent
);
box-shadow:
inset 3px 0 0 #70b33f;
}
.tet-plan-status {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 1px solid #4a4a4a;
border-radius: 50%;
background: #252525;
color: #999;
font-size: 11px;
font-weight: 700;
}
.tet-complete .tet-plan-status {
border-color: #598b37;
background: #314326;
color: #a8dc71;
}
.tet-current .tet-plan-status {
border-color: #699c45;
color: #a8dc71;
}
.tet-plan-course {
min-width: 0;
}
.tet-plan-course strong {
display: block;
overflow: hidden;
color: #eee;
font-size: 12px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.tet-plan-course span {
display: block;
margin-top: 3px;
color: #888;
font-size: 9px;
}
.tet-plan-state {
padding: 3px 7px;
border: 1px solid #555;
border-radius: 3px;
background: #292929;
color: #aaa;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.3px;
}
.tet-state-current {
border-color: #638e45;
background: #33432a;
color: #a8dc71;
}
.tet-state-complete {
border-color: #527839;
background: #2f3d27;
color: #9bce69;
}
.tet-perks-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
padding: 7px;
background: #303030;
}
.tet-perk-card {
min-width: 0;
padding: 7px 9px;
border: 1px solid #222;
border-radius: 4px;
background: linear-gradient(180deg, #383838, #2e2e2e);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.035);
}
.tet-perk-top {
display: flex;
align-items: flex-start;
gap: 7px;
}
.tet-perk-icon {
width: 22px;
height: 22px;
flex: 0 0 22px;
display: grid;
place-items: center;
border: 1px solid #4a4a4a;
border-radius: 50%;
background: #252525;
color: #9bd85f;
font-size: 11px;
}
.tet-perk-content {
min-width: 0;
}
.tet-perk-content strong {
display: block;
color: #eee;
font-size: 10px;
line-height: 1.25;
}
.tet-perk-amount {
display: inline-block;
margin-top: 5px;
color: #9bd85f;
font-size: 17px;
font-weight: 700;
text-shadow: 0 1px 1px #000;
}
.tet-perk-courses {
display: block;
margin-top: 6px;
color: #888;
font-size: 9px;
line-height: 1.4;
}
@media (max-width: 680px) {
.tet-perks-grid {
grid-template-columns: 1fr;
}
}
.tet-empty {
padding: 24px 14px;
color: #999;
text-align: center;
font-size: 11px;
}
.tet-empty strong {
display: block;
margin-bottom: 6px;
color: #eee;
font-size: 13px;
}
.tet-footer {
min-height: 34px;
display: flex;
align-items: center;
padding: 7px 10px;
border-top: 1px solid #111;
background: #282828;
color: #777;
font-size: 9px;
}
.tet-link-button {
padding: 0;
border: 0;
background: transparent;
color: var(--tet-link);
cursor: pointer;
font-size: 9px;
font-weight: 700;
}
.tet-collapsed .tet-body,
.tet-collapsed .tet-footer {
display: none;
}
#${MODAL_ID} {
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
place-items: center;
padding: 18px;
background: rgba(0,0,0,0.72);
backdrop-filter: blur(3px);
}
#${MODAL_ID},
#${MODAL_ID} * {
box-sizing: border-box;
}
.tet-modal {
width: min(820px, 96vw);
max-height: min(780px, 90vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #111;
border-radius: 5px;
background: #202020;
color: #eee;
box-shadow:
0 20px 70px rgba(0,0,0,0.65);
font-family:
Arial,
Helvetica,
sans-serif;
}
.tet-modal-header {
min-height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-bottom: 1px solid #111;
background:
linear-gradient(
180deg,
#3c3c3c,
#303030
);
}
.tet-modal-header strong {
display: block;
color: #eee;
font-size: 14px;
}
.tet-modal-header span {
display: block;
margin-top: 3px;
color: #999;
font-size: 10px;
}
.tet-modal-close {
width: 34px;
height: 32px;
border: 1px solid #222;
border-radius: 4px;
background:
linear-gradient(
180deg,
#484848,
#333
);
color: #bbb;
cursor: pointer;
font-size: 18px;
}
.tet-modal-controls {
display: grid;
grid-template-columns:
minmax(0, 1fr)
210px;
gap: 8px;
padding: 10px;
border-bottom: 1px solid #222;
background: #292929;
}
.tet-modal-controls input,
.tet-modal-controls select {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid #555;
border-radius: 4px;
outline: none;
background: #222;
color: #eee;
font-size: 11px;
}
.tet-modal-controls input:focus,
.tet-modal-controls select:focus {
border-color: #777;
}
.tet-modal-content {
min-height: 0;
display: grid;
grid-template-columns:
minmax(0, 1fr)
minmax(300px, 0.75fr);
overflow: hidden;
}
.tet-catalogue-pane,
.tet-plan-pane {
min-height: 0;
display: flex;
flex-direction: column;
}
.tet-catalogue-pane {
border-right: 1px solid #222;
}
.tet-pane-title {
min-height: 38px;
display: flex;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid #222;
background: #333;
color: #bbb;
font-size: 10px;
font-weight: 700;
}
.tet-course-results,
.tet-modal-plan-list {
min-height: 0;
overflow-y: auto;
padding: 7px;
background: #292929;
}
.tet-course-option {
min-height: 46px;
display: grid;
grid-template-columns:
minmax(0, 1fr)
auto;
align-items: center;
gap: 8px;
padding: 8px;
border-bottom:
1px solid rgba(255,255,255,0.06);
}
.tet-course-option strong {
display: block;
color: #eee;
font-size: 11px;
}
.tet-course-option span {
display: block;
margin-top: 3px;
color: #888;
font-size: 9px;
}
.tet-course-option button {
min-width: 58px;
height: 27px;
border: 1px solid #222;
border-radius: 4px;
background:
linear-gradient(
180deg,
#555,
#393939
);
color: #eee;
cursor: pointer;
font-size: 9px;
font-weight: 700;
}
.tet-course-option button:disabled {
background: #333;
color: #777;
cursor: default;
}
.tet-modal-plan-row {
display: grid;
grid-template-columns:
minmax(0, 1fr)
auto;
gap: 7px;
align-items: center;
padding: 8px;
border-bottom:
1px solid rgba(255,255,255,0.06);
}
.tet-modal-plan-row strong {
display: block;
overflow: hidden;
color: #eee;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tet-modal-plan-row span {
display: block;
margin-top: 3px;
color: #888;
font-size: 9px;
}
.tet-row-actions {
display: flex;
gap: 4px;
}
.tet-row-actions button {
width: 27px;
height: 27px;
border: 1px solid #222;
border-radius: 4px;
background:
linear-gradient(
180deg,
#484848,
#333
);
color: #bbb;
cursor: pointer;
}
.tet-row-actions button:hover {
color: #fff;
}
.tet-row-actions button.tet-remove {
color: #d88;
}
.tet-modal-footer {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid #222;
background: #292929;
}
@media (max-width: 1000px) {
.tet-summary-grid {
grid-template-columns:
repeat(
2,
minmax(0, 1fr)
);
}
.tet-current-card {
grid-column:
1 / -1;
}
}
@media (max-width: 820px) {
.tet-modal-content {
display: block;
overflow-y: auto;
}
.tet-catalogue-pane {
border-right: 0;
border-bottom:
1px solid #222;
}
.tet-course-results,
.tet-modal-plan-list {
max-height: 310px;
}
}
@media (max-width: 540px) {
.tet-brand p {
display: none;
}
.tet-summary-grid {
grid-template-columns:
1fr;
}
.tet-current-card {
grid-column: auto;
}
.tet-section-header {
flex-wrap: wrap;
}
.tet-modal-controls {
grid-template-columns:
1fr;
}
}
`;
document.head.appendChild(
style
);
}
/* =========================================================
TRACKER CREATION
========================================================= */
function createTracker() {
if (
document.getElementById(
TRACKER_ID
)
) {
return;
}
const educationRoot =
getEducationRoot();
if (!educationRoot) {
return;
}
const tracker =
document.createElement(
'section'
);
tracker.id =
TRACKER_ID;
educationRoot.insertAdjacentElement(
'afterend',
tracker
);
renderTracker();
}
/* =========================================================
TRACKER RENDER
========================================================= */
function renderTracker() {
const tracker =
document.getElementById(
TRACKER_ID
);
if (!tracker) {
return;
}
const collapsed =
localStorage.getItem(
STORAGE.collapsed
) === 'true';
if (state.loading) {
tracker.innerHTML = `
<div class="tet-shell">
<header class="tet-header">
<div class="tet-brand-mark">
🎓
</div>
<div class="tet-brand">
<strong class="tet-brand-title">
EDUCATION TRACKER
</strong>
<p>
COURSE PROGRESS & PERSONAL EDUCATION PLAN
</p>
</div>
</header>
<div class="tet-loading">
<div>
<strong>
Loading Education Data
</strong>
Reading current course and education progress…
</div>
</div>
</div>
`;
return;
}
const categoryProgress =
calculateCategoryProgress();
const currentCourse =
state.currentCourse;
const nowTimestamp =
Math.floor(
Date.now() / 1000
);
const secondsRemaining =
currentCourse?.endTimestamp
? Math.max(
0,
currentCourse.endTimestamp -
nowTimestamp
)
: 0;
const completedPlanCount =
state.plan.filter(
item =>
item.complete
).length;
const nextPlanItem =
getNextPlanItem();
const planTimeRemaining =
getPlanTimeRemaining();
const remainingPlanCount =
getRemainingPlanCount();
const activePerks =
buildActivePerks();
const categoryPercentage =
categoryProgress.total > 0
? (
categoryProgress.completed /
categoryProgress.total
) * 100
: 0;
const planRows =
state.plan.map(
(
item,
index
) => {
const current =
isCurrentPlanItem(
item
);
let statusText =
'PLANNED';
let statusClass =
'';
let statusIcon =
index + 1;
if (item.complete) {
statusText =
'COMPLETE';
statusClass =
'tet-state-complete';
statusIcon =
'✓';
} else if (current) {
statusText =
'IN PROGRESS';
statusClass =
'tet-state-current';
statusIcon =
'◉';
}
return `
<div
class="
tet-plan-row
${
item.complete
? 'tet-complete'
: ''
}
${
current
? 'tet-current'
: ''
}
"
>
<div class="tet-plan-status">
${escapeHtml(
statusIcon
)}
</div>
<div class="tet-plan-course">
<strong>
${escapeHtml(
item.name
)}
</strong>
<span>
${escapeHtml(
item.category
)}
${
(
current &&
currentCourse?.endTimestamp
)
? ` · ${escapeHtml(
formatPlanDuration(
secondsRemaining
)
)}`
: (
item.duration
? ` · ${escapeHtml(
formatPlanDuration(
item.duration
)
)}`
: ''
)
}
</span>
</div>
<div
class="
tet-plan-state
${statusClass}
"
>
${statusText}
</div>
</div>
`;
}
).join('');
tracker.innerHTML = `
<div
class="
tet-shell
${
collapsed
? 'tet-collapsed'
: ''
}
"
>
<header class="tet-header">
<div class="tet-brand-mark">
🎓
</div>
<div class="tet-brand">
<strong class="tet-brand-title">
EDUCATION TRACKER
</strong>
<p>
COURSE PROGRESS & PERSONAL EDUCATION PLAN
</p>
</div>
<div class="tet-spacer"></div>
<button
id="tet-refresh"
class="tet-header-button"
type="button"
title="Refresh education data"
>
↻
</button>
<button
id="tet-settings"
class="tet-header-button"
type="button"
title="API settings"
>
⚙
</button>
<button
id="tet-collapse"
class="tet-header-button"
type="button"
>
${
collapsed
? '⌄'
: '⌃'
}
</button>
</header>
<div class="tet-body">
${
state.error
? `
<div class="tet-error">
${escapeHtml(
state.error
)}
</div>
`
: ''
}
<div class="tet-summary-grid">
<div class="tet-card tet-current-card">
<span class="tet-card-label">
CURRENT COURSE
</span>
<strong class="tet-current-course">
${
currentCourse?.name
? escapeHtml(
currentCourse.name
)
: 'No active education course'
}
</strong>
${
currentCourse?.category
? `
<span class="tet-current-category">
${escapeHtml(
currentCourse.category
.toUpperCase()
)}
</span>
`
: ''
}
</div>
<div class="tet-card">
<span class="tet-card-label">
TIME REMAINING
</span>
<strong
id="tet-time-remaining"
class="
tet-stat-value
tet-time-value
"
>
${
currentCourse?.endTimestamp
? escapeHtml(
formatDuration(
secondsRemaining
)
)
: '—'
}
</strong>
<span class="tet-stat-subtext">
${
currentCourse?.endTimestamp
? `Finishes ${escapeHtml(
formatDateTime(
currentCourse.endTimestamp
)
)}`
: 'No completion time available'
}
</span>
</div>
<div class="tet-card">
<span class="tet-card-label">
CATEGORY REMAINING
</span>
<strong class="tet-stat-value">
${formatNumber(
categoryProgress.remaining
)}
</strong>
<span class="tet-stat-subtext">
${formatNumber(
categoryProgress.completed
)}
of
${formatNumber(
categoryProgress.total
)}
completed
</span>
<div class="tet-course-progress">
<div
class="tet-course-progress-fill"
style="
width:
${Math.max(
0,
Math.min(
100,
categoryPercentage
)
)}%;
"
></div>
</div>
</div>
<div class="tet-card">
<span class="tet-card-label">
NEXT PLANNED COURSE
</span>
<strong
class="tet-current-course"
style="font-size: 13px;"
>
${
nextPlanItem
? escapeHtml(
nextPlanItem.name
)
: 'No course queued'
}
</strong>
<span class="tet-stat-subtext">
${
nextPlanItem
? escapeHtml(
nextPlanItem.category
)
: 'Build your personal course plan'
}
</span>
</div>
<div class="tet-card">
<span class="tet-card-label">
PLANNED TIME REMAINING
</span>
<strong
class="
tet-stat-value
tet-plan-time-value
"
>
${escapeHtml(
formatPlanDuration(
planTimeRemaining
)
)}
</strong>
<span class="tet-stat-subtext">
${formatNumber(
remainingPlanCount
)}
planned
${
remainingPlanCount === 1
? 'course'
: 'courses'
}
remaining
</span>
</div>
</div>
<section class="tet-section">
<header class="tet-section-header">
<strong>
MY EDUCATION PLAN
</strong>
<span>
${formatNumber(
completedPlanCount
)}
/
${formatNumber(
state.plan.length
)}
COMPLETE
</span>
<div class="tet-spacer"></div>
${
completedPlanCount > 0
? `
<button
id="tet-clear-completed"
class="tet-secondary-button"
type="button"
>
CLEAR COMPLETED
</button>
`
: ''
}
<button
id="tet-manage-plan"
class="tet-primary-button"
type="button"
>
MANAGE PLAN
</button>
</header>
${
state.plan.length
? `
<div class="tet-plan-list">
${planRows}
</div>
`
: `
<div class="tet-empty">
<strong>
Your education plan is empty.
</strong>
Add courses to create
a personal ordered checklist.
</div>
`
}
</section>
<section class="tet-section">
<header class="tet-section-header">
<strong>
ACTIVE EDUCATION PERKS
</strong>
<span>
${formatNumber(activePerks.length)} ACTIVE
</span>
</header>
${
activePerks.length
? `
<div class="tet-perks-grid">
${activePerks.map(perk => `
<div class="tet-perk-card">
<div class="tet-perk-top">
<div class="tet-perk-icon">
✓
</div>
<div class="tet-perk-content">
<strong>
${escapeHtml(
perk.title
)}
</strong>
${
perk.type === 'percentage'
? `
<span class="tet-perk-amount">
+${formatNumber(perk.amount)}% ACTIVE
</span>
`
: ''
}
</div>
</div>
</div>
`).join('')}
</div>
`
: `
<div class="tet-empty">
<strong>
No cached education perks found.
</strong>
Visit the Torn home page once to sync
your active Education perks.
</div>
`
}
</section>
</div>
<footer class="tet-footer">
<span>
Completed courses are checked automatically
from your Torn education data.
</span>
<div class="tet-spacer"></div>
<button
id="tet-footer-settings"
class="tet-link-button"
type="button"
>
API SETTINGS
</button>
</footer>
</div>
`;
wireTrackerEvents();
startCountdown();
}
/* =========================================================
TRACKER EVENTS
========================================================= */
function wireTrackerEvents() {
document.getElementById(
'tet-refresh'
)?.addEventListener(
'click',
() =>
loadEducationData(true)
);
document.getElementById(
'tet-settings'
)?.addEventListener(
'click',
showApiKeyDialog
);
document.getElementById(
'tet-footer-settings'
)?.addEventListener(
'click',
showApiKeyDialog
);
document.getElementById(
'tet-collapse'
)?.addEventListener(
'click',
() => {
const currentlyCollapsed =
localStorage.getItem(
STORAGE.collapsed
) === 'true';
localStorage.setItem(
STORAGE.collapsed,
String(
!currentlyCollapsed
)
);
renderTracker();
}
);
document.getElementById(
'tet-manage-plan'
)?.addEventListener(
'click',
openPlanModal
);
document.getElementById(
'tet-clear-completed'
)?.addEventListener(
'click',
() => {
if (
!window.confirm(
'Remove all completed courses from your education plan?'
)
) {
return;
}
clearCompletedPlanItems();
}
);
}
/* =========================================================
CURRENT COURSE COUNTDOWN
========================================================= */
function startCountdown() {
if (
state.countdownTimer
) {
clearInterval(
state.countdownTimer
);
}
state.countdownTimer =
window.setInterval(
() => {
const element =
document.getElementById(
'tet-time-remaining'
);
if (
!element ||
!state.currentCourse
?.endTimestamp
) {
return;
}
const nowTimestamp =
Math.floor(
Date.now() /
1000
);
const secondsRemaining =
Math.max(
0,
state.currentCourse
.endTimestamp -
nowTimestamp
);
element.textContent =
formatDuration(
secondsRemaining
);
if (
secondsRemaining <= 0
) {
clearInterval(
state.countdownTimer
);
window.setTimeout(
() =>
loadEducationData(
true
),
3000
);
}
},
1000
);
}
/* =========================================================
API KEY DIALOG
========================================================= */
function showApiKeyDialog() {
document.getElementById(
API_DIALOG_ID
)?.remove();
const overlay =
document.createElement(
'div'
);
overlay.id =
API_DIALOG_ID;
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 100001;
display: grid;
place-items: center;
padding: 18px;
background: rgba(0,0,0,0.72);
backdrop-filter: blur(3px);
`;
overlay.innerHTML = `
<div
style="
width: min(440px, 96vw);
padding: 14px;
border: 1px solid #111;
border-radius: 5px;
background: #303030;
color: #eee;
box-shadow:
0 20px 70px rgba(0,0,0,0.65);
font-family:
Arial,
Helvetica,
sans-serif;
"
>
<div
style="
color: #eee;
font-size: 14px;
font-weight: 700;
"
>
EDUCATION TRACKER API KEY
</div>
<p
style="
margin: 8px 0 12px;
color: #aaa;
font-size: 11px;
line-height: 1.45;
"
>
Enter your Torn API key.
The key is stored only in this browser.
</p>
<input
id="tet-api-key-input"
type="password"
autocomplete="off"
value="${escapeHtml(
getApiKey()
)}"
placeholder="Enter Torn API key"
style="
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #555;
border-radius: 4px;
outline: none;
background: #222;
color: #eee;
font-size: 12px;
"
>
<div
style="
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
"
>
<button
id="tet-api-cancel"
type="button"
style="
min-height: 30px;
padding: 0 11px;
border: 1px solid #222;
border-radius: 4px;
background: #444;
color: #bbb;
cursor: pointer;
"
>
CANCEL
</button>
<button
id="tet-api-save"
type="button"
style="
min-height: 30px;
padding: 0 11px;
border: 1px solid #222;
border-radius: 4px;
background:
linear-gradient(
180deg,
#555,
#393939
);
color: #eee;
cursor: pointer;
font-weight: 700;
"
>
SAVE & LOAD
</button>
</div>
</div>
`;
document.body.appendChild(
overlay
);
const input =
document.getElementById(
'tet-api-key-input'
);
input?.focus();
document.getElementById(
'tet-api-cancel'
)?.addEventListener(
'click',
() =>
overlay.remove()
);
document.getElementById(
'tet-api-save'
)?.addEventListener(
'click',
() => {
const apiKey =
input?.value.trim() ||
'';
if (
!/^[A-Za-z0-9]{16}$/
.test(apiKey)
) {
window.alert(
'Please enter a valid 16-character Torn API key.'
);
return;
}
saveApiKey(
apiKey
);
localStorage.removeItem(
STORAGE.catalogueCache
);
localStorage.removeItem(
STORAGE.userCache
);
overlay.remove();
loadEducationData(
true
);
}
);
overlay.addEventListener(
'click',
event => {
if (
event.target ===
overlay
) {
overlay.remove();
}
}
);
}
/* =========================================================
PLAN MODAL
========================================================= */
function openPlanModal() {
document.getElementById(
MODAL_ID
)?.remove();
const modal =
document.createElement(
'div'
);
modal.id =
MODAL_ID;
document.body.appendChild(
modal
);
renderPlanModal();
}
function closePlanModal() {
document.getElementById(
MODAL_ID
)?.remove();
}
function renderPlanModal() {
const modal =
document.getElementById(
MODAL_ID
);
if (!modal) {
return;
}
const previousSearch =
modal.querySelector(
'#tet-course-search'
)?.value ||
'';
const previousCategory =
modal.querySelector(
'#tet-course-category'
)?.value ||
'All Categories';
const categories = [
'All Categories',
...new Set(
state.catalogue.map(
course =>
course.category
)
)
];
modal.innerHTML = `
<div class="tet-modal">
<header class="tet-modal-header">
<div class="tet-brand-mark">
🎓
</div>
<div>
<strong>
MANAGE EDUCATION PLAN
</strong>
<span>
Add courses and arrange them
in your preferred order.
</span>
</div>
<div class="tet-spacer"></div>
<button
id="tet-modal-close"
class="tet-modal-close"
type="button"
>
×
</button>
</header>
<div class="tet-modal-controls">
<input
id="tet-course-search"
type="search"
value="${escapeHtml(
previousSearch
)}"
placeholder="Search courses…"
>
<select
id="tet-course-category"
>
${
categories.map(
category => `
<option
value="${escapeHtml(
category
)}"
${
category ===
previousCategory
? 'selected'
: ''
}
>
${escapeHtml(
category
)}
</option>
`
).join('')
}
</select>
</div>
<div class="tet-modal-content">
<section class="tet-catalogue-pane">
<div class="tet-pane-title">
AVAILABLE COURSES
</div>
<div
id="tet-course-results"
class="tet-course-results"
></div>
</section>
<section class="tet-plan-pane">
<div class="tet-pane-title">
MY ORDERED PLAN
</div>
<div class="tet-modal-plan-list">
${
state.plan.length
? state.plan.map(
(
item,
index
) => `
<div class="tet-modal-plan-row">
<div>
<strong>
${index + 1}.
${escapeHtml(
item.name
)}
</strong>
<span>
${escapeHtml(
item.category
)}
${
item.duration
? ` · ${escapeHtml(
formatPlanDuration(
item.duration
)
)}`
: ''
}
${
item.complete
? ' · COMPLETE'
: ''
}
</span>
</div>
<div class="tet-row-actions">
<button
type="button"
data-plan-up="${escapeHtml(
item.id
)}"
>
↑
</button>
<button
type="button"
data-plan-down="${escapeHtml(
item.id
)}"
>
↓
</button>
<button
type="button"
class="tet-remove"
data-plan-remove="${escapeHtml(
item.id
)}"
>
×
</button>
</div>
</div>
`
).join('')
: `
<div class="tet-empty">
No courses added yet.
</div>
`
}
</div>
</section>
</div>
<footer class="tet-modal-footer">
<button
id="tet-modal-clear"
class="tet-danger-button"
type="button"
>
CLEAR PLAN
</button>
<div class="tet-spacer"></div>
<button
id="tet-modal-done"
class="tet-primary-button"
type="button"
>
DONE
</button>
</footer>
</div>
`;
wirePlanModalEvents();
renderCourseResults();
}
function renderCourseResults() {
const modal =
document.getElementById(
MODAL_ID
);
const results =
modal?.querySelector(
'#tet-course-results'
);
if (!results) {
return;
}
const search =
normaliseText(
modal.querySelector(
'#tet-course-search'
)?.value ||
''
).toLowerCase();
const category =
modal.querySelector(
'#tet-course-category'
)?.value ||
'All Categories';
const filtered =
state.catalogue.filter(
course => {
const searchMatches =
!search ||
course.name
.toLowerCase()
.includes(search) ||
course.category
.toLowerCase()
.includes(search);
const categoryMatches =
category ===
'All Categories' ||
course.category ===
category;
return (
searchMatches &&
categoryMatches
);
}
);
results.innerHTML =
filtered.length
? filtered.map(
course => {
const alreadyAdded =
state.plan.some(
item =>
String(item.id) ===
course.id
);
const complete =
state.completedCourseIds
.has(course.id);
return `
<div class="tet-course-option">
<div>
<strong>
${escapeHtml(
course.name
)}
</strong>
<span>
${escapeHtml(
course.category
)}
${
course.duration
? ` · ${escapeHtml(
formatPlanDuration(
course.duration
)
)}`
: ''
}
${
complete
? ' · ALREADY COMPLETE'
: ''
}
</span>
</div>
<button
type="button"
data-course-add="${escapeHtml(
course.id
)}"
${
alreadyAdded
? 'disabled'
: ''
}
>
${
alreadyAdded
? 'ADDED'
: 'ADD'
}
</button>
</div>
`;
}
).join('')
: `
<div class="tet-empty">
No matching courses found.
</div>
`;
results.querySelectorAll(
'[data-course-add]'
).forEach(
button => {
button.addEventListener(
'click',
() => {
addCourseToPlan(
button.dataset
.courseAdd
);
}
);
}
);
}
function wirePlanModalEvents() {
const modal =
document.getElementById(
MODAL_ID
);
if (!modal) {
return;
}
modal.querySelector(
'#tet-modal-close'
)?.addEventListener(
'click',
closePlanModal
);
modal.querySelector(
'#tet-modal-done'
)?.addEventListener(
'click',
closePlanModal
);
modal.querySelector(
'#tet-course-search'
)?.addEventListener(
'input',
renderCourseResults
);
modal.querySelector(
'#tet-course-category'
)?.addEventListener(
'change',
renderCourseResults
);
modal.querySelectorAll(
'[data-plan-up]'
).forEach(
button => {
button.addEventListener(
'click',
() => {
movePlanItem(
button.dataset
.planUp,
-1
);
}
);
}
);
modal.querySelectorAll(
'[data-plan-down]'
).forEach(
button => {
button.addEventListener(
'click',
() => {
movePlanItem(
button.dataset
.planDown,
1
);
}
);
}
);
modal.querySelectorAll(
'[data-plan-remove]'
).forEach(
button => {
button.addEventListener(
'click',
() => {
removeCourseFromPlan(
button.dataset
.planRemove
);
}
);
}
);
modal.querySelector(
'#tet-modal-clear'
)?.addEventListener(
'click',
() => {
if (
!state.plan.length ||
!window.confirm(
'Clear your entire education plan?'
)
) {
return;
}
state.plan = [];
savePlan();
renderTracker();
renderPlanModal();
}
);
modal.addEventListener(
'click',
event => {
if (
event.target ===
modal
) {
closePlanModal();
}
}
);
}
/* =========================================================
DATA LOADING
========================================================= */
async function loadEducationData(
forceRefresh = false
) {
if (
state.loading
) {
return;
}
const apiKey =
getApiKey();
if (
!/^[A-Za-z0-9]{16}$/
.test(apiKey)
) {
state.error =
'Enter your Torn API key to load education progress.';
renderTracker();
showApiKeyDialog();
return;
}
state.loading = true;
state.error = '';
renderTracker();
try {
let catalogueData =
forceRefresh
? null
: readCache(
STORAGE.catalogueCache
);
let userData =
forceRefresh
? null
: readCache(
STORAGE.userCache
);
if (
!catalogueData
) {
catalogueData =
await apiRequest(
'/torn/education'
);
writeCache(
STORAGE.catalogueCache,
catalogueData
);
}
if (
!userData
) {
userData =
await apiRequest(
'/user/education'
);
writeCache(
STORAGE.userCache,
userData
);
}
state.catalogue =
normaliseCatalogueResponse(
catalogueData
);
const userEducation =
normaliseUserEducationResponse(
userData
);
state.completedCourseIds =
userEducation.completedIds;
const domCurrentCourse =
readCurrentCourseFromDom();
state.currentCourse =
enrichCurrentCourse(
userEducation.currentCourse ||
domCurrentCourse
);
if (
state.currentCourse &&
domCurrentCourse?.endTimestamp
) {
state.currentCourse
.endTimestamp =
domCurrentCourse
.endTimestamp;
}
if (
state.currentCourse &&
!state.currentCourse.name &&
domCurrentCourse?.name
) {
state.currentCourse.name =
domCurrentCourse.name;
}
state.currentCourse =
enrichCurrentCourse(
state.currentCourse
);
synchronisePlan();
if (
!state.catalogue.length
) {
throw new Error(
'Torn returned education data, but the course catalogue could not be read.'
);
}
} catch (error) {
state.error =
error.message ||
'Education data could not be loaded.';
const domCurrentCourse =
readCurrentCourseFromDom();
if (
domCurrentCourse
) {
state.currentCourse =
enrichCurrentCourse(
domCurrentCourse
);
}
} finally {
state.loading = false;
renderTracker();
watchForCurrentCourseTimestamp();
if (
state.catalogue.length &&
state.completedCourseIds.size
) {
window.setTimeout(
runOutcomeHarvestInBackground,
0
);
}
}
}
/* =========================================================
PAGE OBSERVATION
========================================================= */
function handlePageChange() {
if (
!isEducationPage()
) {
stopCurrentCourseTimestampWatch();
document.getElementById(
TRACKER_ID
)?.remove();
document.getElementById(
MODAL_ID
)?.remove();
return;
}
injectStyles();
createTracker();
watchForCurrentCourseTimestamp();
if (
document.getElementById(
TRACKER_ID
) &&
!state.loading &&
!state.catalogue.length
) {
loadEducationData(
false
);
}
}
const pageObserver =
new MutationObserver(
() => {
if (
window.location.href !==
state.lastUrl
) {
state.lastUrl =
window.location.href;
window.setTimeout(
handlePageChange,
250
);
return;
}
if (
isEducationPage() &&
!document.getElementById(
TRACKER_ID
) &&
getEducationRoot()
) {
createTracker();
loadEducationData(
false
);
}
}
);
pageObserver.observe(
document.documentElement,
{
childList: true,
subtree: true
}
);
/* =========================================================
INITIALISE
========================================================= */
function initialise() {
if (isTornHomePage()) {
captureEducationPerksFromHome();
return;
}
if (
!isEducationPage()
) {
return;
}
injectStyles();
const waitForEducation =
window.setInterval(
() => {
if (
!getEducationRoot()
) {
return;
}
clearInterval(
waitForEducation
);
createTracker();
loadEducationData(
false
);
},
250
);
window.setTimeout(
() => {
clearInterval(
waitForEducation
);
},
15000
);
}
initialise();
})();