// ==UserScript==
// @name AO3 Statistics tracker
// @description Track statistics on the stats page and display the stats in charts. Stores the stats in the browser's local storage.
// @author Ifky_
// @namespace https://greasyfork.org/en/scripts/518523
// @version 1.0.0
// @history 1.0.0 — Track stats, draw charts, download chart, clean stats, import/export stats, toggle view
// @match https://archiveofourown.org/users/*/stats*
// @match http://archiveofourown.org/users/*/stats*
// @icon https://archiveofourown.org/images/logo.png
// @license GPL-3.0-only
// @grant none
// ==/UserScript==
(function () {
"use strict";
/** ==================================== **/
/* FLAGS MEANT TO BE EDITED: CAPITALIZED */
/* Keep in mind that these might be */
/* reset if the script is auto-updated, */
/* so create a backup somewhere safe */
/** ==================================== **/
// Clean data by removing unnecessary points.
// Options: "day" / "hour" / "month"
const CLEAN_DATA = "hour";
// Theme mode for the chart.
// Options: "light" / "dark"
const THEME_MODE = "light";
// Whether to include or exclude work stats
// Can be a good idea to turn off if you have many works
// Options: true / false
const INCLUDE_WORKS = true;
// The sign used to separate values
// Change it to something else if it clashes with a work name
// Options: any string
const DELIMITER = ";";
const getTheme = (mode) => {
if (mode === "dark") {
return {
text: "#999",
background: "#222",
gridLines: "#333",
userSubscriptions: "#F94144",
kudos: "#F3722C",
commentThreads: "#F9C74F",
bookmarks: "#90BE6D",
subscriptions: "#43AA8B",
wordCount: "#6C8EAD",
hits: "#8552BA",
};
}
else {
// Default to light mode
return {
text: "#777",
background: "#FFF",
gridLines: "#DDD",
userSubscriptions: "#F94144",
kudos: "#F3722C",
commentThreads: "#F9C74F",
bookmarks: "#90BE6D",
subscriptions: "#43AA8B",
wordCount: "#577590",
hits: "#663C91",
};
}
};
const sleep = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
const trackStats = () => {
const newTotalStats = getTotalStats();
storeInLocalStorage(newTotalStats);
if (INCLUDE_WORKS) {
const newWorkStats = getWorkStats();
storeInLocalStorage(newWorkStats);
}
drawChart(chartContainer, canvasTotal, "total");
};
const exportStats = () => {
let csvContent = "data:text/csv;charset=utf-8,";
// Headers
csvContent += "type;workName;statName;date;value\r\n";
const keys = Object.keys(localStorage).filter((key) => isValidKey(key));
keys.forEach((key, index) => {
// Add \r\n to every row but the last one
csvContent +=
[key, localStorage.getItem(key)].join(DELIMITER) +
(index !== keys.length - 1 ? "\r\n" : "");
});
var encodedUri = encodeURI(csvContent);
var link = document.createElement("a");
link.href = encodedUri;
link.download = `stats-${formatDate(new Date()).yyyyMMdd}.csv`;
link.click();
};
const importStats = (event) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
Array.from(target.files).forEach((file) => {
csvFileToStatsRow(file)
.then((rows) => {
storeInLocalStorage(rows);
alert(`${rows.length} rows imported!`);
drawChart(chartContainer, canvasTotal, "total");
})
.catch((error) => {
alert(error);
});
});
// Reset input
target.value = "";
}
};
const csvFileToStatsRow = (file) => {
return new Promise((resolve, reject) => {
// Check if the file is of .csv type
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
reject(new Error("The file must be a .csv file."));
}
const reader = new FileReader();
// Handle the file load event
reader.onload = () => {
if (typeof reader.result === "string") {
const lines = reader.result.split("\r\n");
// Validate header
const header = lines[0];
const [headerType, headerWorkName, headerStatName, headerDate, headerValue,] = header.split(DELIMITER);
if (headerType !== "type" ||
headerWorkName !== "workName" ||
headerStatName !== "statName" ||
headerDate !== "date" ||
headerValue !== "value") {
reject(new Error(`Header(s) could not be inferred: ${header}`));
}
const rows = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const [type, workName, statName, date, value] = line.split(DELIMITER);
try {
validateStatRow({
type,
workName,
statName,
date,
value,
}, i);
const row = {
type: type,
workName: workName,
statName: statName,
date: new Date(date),
value: Number(value),
};
rows.push(row);
}
catch (error) {
reject(error);
}
}
resolve(rows);
}
else {
reject(new Error("File content could not be read as text."));
}
};
reader.onerror = () => {
reject(new Error("An error occurred while reading the file."));
};
reader.readAsText(file);
});
};
let statChart = null;
let statisticsMetaGroup = null;
let statisticsIndexGroup = null;
let canvasTotal = null;
let chartWidth = 300;
const chartHeight = 250;
const chartContainer = document.createElement("div");
chartContainer.id = "chart-container-total";
chartContainer.style.marginBlock = "5px";
chartContainer.style.transition = "height 500ms ease-in-out";
chartContainer.style.overflow = "hidden";
const toggleChartContainer = (container, open) => {
if (open === true) {
container.style.height = `${chartHeight}px`;
return;
}
else if (open === false) {
container.style.height = "0px";
return;
}
if (container.style.height === "0px") {
container.style.height = `${chartHeight}px`;
}
else {
container.style.height = "0px";
}
};
const observer = new MutationObserver(() => {
statChart = document.getElementById("stat_chart");
statisticsMetaGroup = document.querySelector(".statistics.meta.group");
statisticsIndexGroup = document.querySelector(".statistics.index.group");
if (statChart && statisticsMetaGroup && statisticsIndexGroup) {
observer.disconnect(); // Stop observing once the element is found
chartWidth = Math.round(statChart.offsetWidth);
chartContainer.style.width = `${chartWidth}px`;
chartContainer.style.height = `0px`;
statChart.prepend(chartContainer);
canvasTotal = document.createElement("canvas");
canvasTotal.id = "tracked-stats-chart";
canvasTotal.width = chartWidth;
canvasTotal.height = chartHeight;
canvasTotal.style.display = "block";
canvasTotal.style.backgroundColor = "#8888";
chartContainer.append(canvasTotal);
// Append scripts for chart
const chartScript = document.createElement("script");
chartScript.src = "https://cdn.jsdelivr.net/npm/chart.js";
statChart.appendChild(chartScript);
chartScript.onload = () => {
const chartDateScript = document.createElement("script");
chartDateScript.src =
"https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns";
statChart.appendChild(chartDateScript);
chartDateScript.onload = () => {
const buttonContainer = document.createElement("div");
buttonContainer.style.width = "100%";
buttonContainer.style.display = "flex";
buttonContainer.style.justifyContent = "flex-end";
buttonContainer.style.flexWrap = "wrap";
buttonContainer.style.gap = "1em";
buttonContainer.style.paddingInline = "1em";
buttonContainer.style.boxSizing = "border-box";
statisticsMetaGroup.append(buttonContainer);
const chartExists = () => {
// eslint-disable-next-line
const chartStatus = Chart.getChart("tracked-stats-chart");
return chartStatus !== undefined;
};
const downloadChart = async () => {
if (!chartExists) {
drawChart(chartContainer, canvasTotal, "total");
// Sleep to allow chart to be drawn
await sleep(500);
}
const url = canvasTotal.toDataURL("image/png");
var a = document.createElement("a");
a.href = url;
a.download = `stats-chart-${formatDate(new Date()).yyyyMMdd}.png`;
a.click();
};
// Button to track statistics
const logButton = document.createElement("button");
logButton.style.cursor = "pointer";
logButton.innerText = "Track";
logButton.onclick = trackStats;
buttonContainer.append(logButton);
// Button to draw chart
const drawButton = document.createElement("button");
drawButton.style.cursor = "pointer";
drawButton.innerText = "Draw Chart";
drawButton.onclick = () => drawChart(chartContainer, canvasTotal, "total");
buttonContainer.append(drawButton);
// Button to download chart
const downloadButton = document.createElement("button");
downloadButton.style.cursor = "pointer";
downloadButton.innerText = "Download";
downloadButton.onclick = downloadChart;
buttonContainer.append(downloadButton);
// Button to clean stats
const cleanButton = document.createElement("button");
cleanButton.style.cursor = "pointer";
cleanButton.innerText = "Clean Stats";
cleanButton.onclick = () => cleanStats(CLEAN_DATA);
buttonContainer.append(cleanButton);
// Button to export stats
const exportButton = document.createElement("button");
exportButton.style.cursor = "pointer";
exportButton.innerText = "Export";
exportButton.onclick = exportStats;
buttonContainer.append(exportButton);
// Actions container (for styling purposes)
const actionsContainer = document.createElement("div");
actionsContainer.classList.add("actions");
buttonContainer.append(actionsContainer);
// Input to import stats
const importInput = document.createElement("input");
importInput.id = "import-stats-input";
importInput.type = "file";
importInput.accept = ".csv";
importInput.multiple = true;
importInput.style.height = "0";
importInput.style.width = "0";
importInput.style.overflow = "hidden !important";
importInput.style.opacity = "0";
importInput.style.position = "absolute";
importInput.style.zIndex = "-100";
importInput.onchange = importStats;
actionsContainer.append(importInput);
// Label to import stats
const importLabel = document.createElement("label");
importLabel.htmlFor = "import-stats-input";
importLabel.style.cursor = "pointer";
importLabel.style.margin = "0";
importLabel.innerText = "Import";
importLabel.classList.add("button");
actionsContainer.append(importLabel);
// Toggle to hide/show chart
const toggleLabel = document.createElement("button");
toggleLabel.style.cursor = "pointer";
toggleLabel.innerText = "Toggle View";
toggleLabel.onclick = () => toggleChartContainer(chartContainer);
buttonContainer.append(toggleLabel);
// Buttons for each work
const workElements = statisticsIndexGroup.querySelectorAll(".index.group>li:not(.group)");
workElements.forEach((item) => {
const workName = item.querySelector("dt>a:link").innerHTML;
const chartContainer = document.createElement("div");
chartContainer.id = `chart-container:${workName}`;
chartContainer.style.marginBlock = "5px";
chartContainer.style.transition = "height 500ms ease-in-out";
chartContainer.style.overflow = "hidden";
chartContainer.style.width = `100%`;
chartContainer.style.height = `0px`;
item.append(chartContainer);
const canvasWork = document.createElement("canvas");
canvasWork.id = `stats-chart:${workName}`;
canvasWork.width = item.offsetWidth;
canvasWork.height = chartHeight;
canvasWork.style.display = "block";
canvasWork.style.backgroundColor = "#8888";
chartContainer.append(canvasWork);
const buttonContainer = document.createElement("div");
buttonContainer.style.width = "100%";
buttonContainer.style.display = "flex";
buttonContainer.style.justifyContent = "flex-end";
buttonContainer.style.flexWrap = "wrap";
buttonContainer.style.gap = "1em";
buttonContainer.style.paddingInline = "1em";
buttonContainer.style.boxSizing = "border-box";
const drawButton = document.createElement("button");
drawButton.style.cursor = "pointer";
drawButton.innerText = "Draw";
drawButton.onclick = () => drawChart(chartContainer, canvasWork, "work", workName);
buttonContainer.append(drawButton);
const toggleLabel = document.createElement("button");
toggleLabel.style.cursor = "pointer";
toggleLabel.innerText = "Toggle";
toggleLabel.onclick = () => toggleChartContainer(chartContainer);
buttonContainer.append(toggleLabel);
const dt = item.querySelector("dt");
dt.append(buttonContainer);
});
};
};
}
});
observer.observe(document.body, { childList: true, subtree: true });
const drawChart = async (container, canvas, type, workName) => {
// If chart is hidden, show it
if (container.style.height === "0px") {
toggleChartContainer(container, true);
await sleep(1000);
}
let storageStats = getStatsFromLocalStorage().filter((item) => item.type === type);
if (workName) {
storageStats = storageStats.filter((item) => item.workName === workName);
}
const { datasets } = statRowToDataSet(storageStats, type);
// Destroy existing chart
// eslint-disable-next-line
const chartStatus = Chart.getChart(canvas.id);
if (chartStatus !== undefined) {
chartStatus.destroy();
}
// eslint-disable-next-line
new Chart(canvas.getContext("2d"), {
type: "line",
data: {
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: "time",
time: {
unit: "day",
},
distribution: "linear",
grid: {
color: getTheme(THEME_MODE).gridLines,
},
ticks: {
color: getTheme(THEME_MODE).text,
},
},
y: {
ticks: {
precision: 0,
color: getTheme(THEME_MODE).text,
},
grid: {
color: getTheme(THEME_MODE).gridLines,
},
},
},
plugins: {
customCanvasBackgroundColor: {
color: getTheme(THEME_MODE).background,
},
tooltip: {
callbacks: {
title: function (context) {
const date = new Date(context[0].parsed.x);
return date.toLocaleString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false, // Ensure 24-hour clock
});
},
},
},
},
},
plugins: [plugin],
});
};
const getStatKey = (type, workName, statName, date) => {
return `${type};${workName};${statName};${date}`;
};
// Element names (arbitrary) and their DOM identifier
const elementsTotal = new Map([
[
"user-subscriptions",
{
selector: "dd.user.subscriptions",
color: getTheme(THEME_MODE).userSubscriptions,
hidden: true,
},
],
[
"kudos",
{
selector: "dd.kudos",
color: getTheme(THEME_MODE).kudos,
hidden: true,
},
],
[
"comment-threads",
{
selector: "dd.comment.thread",
color: getTheme(THEME_MODE).commentThreads,
hidden: true,
},
],
[
"bookmarks",
{
selector: "dd.bookmarks",
color: getTheme(THEME_MODE).bookmarks,
hidden: true,
},
],
[
"subscriptions",
{
selector: "dd.subscriptions:not(.user)",
color: getTheme(THEME_MODE).subscriptions,
hidden: true,
},
],
[
"word-count",
{
selector: "dd.words",
color: getTheme(THEME_MODE).wordCount,
hidden: true,
},
],
[
"hits",
{
selector: "dd.hits",
color: getTheme(THEME_MODE).hits,
hidden: false,
},
],
]);
const elementsWork = new Map([
[
"kudos",
{
selector: "dd.kudos",
color: getTheme(THEME_MODE).kudos,
hidden: true,
},
],
[
"comments",
{
selector: "dd.comments",
color: getTheme(THEME_MODE).commentThreads,
hidden: true,
},
],
[
"bookmarks",
{
selector: "dd.bookmarks",
color: getTheme(THEME_MODE).bookmarks,
hidden: true,
},
],
[
"subscriptions",
{
selector: "dd.subscriptions",
color: getTheme(THEME_MODE).subscriptions,
hidden: true,
},
],
[
"words",
{
selector: "span.words",
color: getTheme(THEME_MODE).wordCount,
hidden: true,
},
],
[
"hits",
{
selector: "dd.hits",
color: getTheme(THEME_MODE).hits,
hidden: false,
},
],
]);
const getTotalStats = () => {
const stats = [];
elementsTotal.forEach((value, key) => {
const valueString = statisticsMetaGroup.querySelector(value.selector).innerHTML;
// Regex to remove any non-digit symbols
const valueNumber = Number(valueString.replace(/\D/g, ""));
stats.push({
type: "total",
workName: "",
statName: key,
date: new Date(),
value: valueNumber,
});
});
return stats;
};
const getWorkStats = () => {
const stats = [];
const workElements = statisticsIndexGroup.querySelectorAll(".index.group>li:not(.group)");
workElements.forEach((elem) => {
const workName = elem.querySelector("dt>a:link").innerHTML;
elementsWork.forEach((value, key) => {
const valueString = elem.querySelector(value.selector);
// Some stats might not exist on a work. Skip them
if (valueString) {
// Regex to remove any non-digit symbols
const valueNumber = Number(valueString.innerHTML.replace(/\D/g, ""));
stats.push({
type: "work",
workName: workName,
statName: key,
date: new Date(),
value: valueNumber,
});
}
});
});
return stats;
};
const storeInLocalStorage = (stats) => {
stats.forEach((stat, index) => {
const key = getStatKey(stat.type, stat.workName, stat.statName, formatDate(stat.date).yyyyMMdd_hhmm);
localStorage.setItem(key, stat.value.toString());
});
};
const isValidKey = (key) => {
return key.startsWith("work") || key.startsWith("total");
};
const getStatsFromLocalStorage = () => {
const rows = [];
const keys = Object.keys(localStorage);
let i = keys.length;
while (i--) {
if (isValidKey(keys[i])) {
const [type, workName, statName, date] = keys[i].split(DELIMITER);
rows.push({
type: type,
workName: workName,
statName: statName,
date: new Date(date),
value: Number(localStorage.getItem(keys[i])),
});
}
}
return rows.sort((a, b) => a.date.getTime() - b.date.getTime());
};
const kebabToTitleCase = (input) => {
return input
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
};
const formatDate = (date) => {
// Helper to pad single digits with leading zero
const pad = (num) => num.toString().padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1); // Months are 0-indexed
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return {
yyyyMMdd_hhmm: `${year}-${month}-${day} ${hours}:${minutes}`,
yyyyMMdd_hh: `${year}-${month}-${day} ${hours}`,
yyyyMMdd: `${year}-${month}-${day}`,
yyyyMM: `${year}-${month}`,
};
};
const statRowToDataSet = (stats, type) => {
const dataset = new Map();
let keys = [];
switch (type) {
case "total":
keys = Array.from(elementsTotal.keys());
break;
case "work":
keys = Array.from(elementsWork.keys());
break;
}
// Find each stat type
keys.forEach((key) => {
const list = [];
// Get the stats for the stat type
stats.forEach((stat) => {
if (stat.statName === key) {
list.push([stat.date, stat.value]);
}
});
// Convert keys from kebab-case to Title Case
dataset.set(kebabToTitleCase(key), list);
});
return {
datasets: Array.from(dataset).map((data, index) => {
const values = data[1].map((entry) => {
return {
x: formatDate(entry[0]).yyyyMMdd_hhmm,
y: entry[1],
};
});
let backgroundColor = "";
let hidden = false;
switch (type) {
case "total":
backgroundColor = elementsTotal.get(keys[index]).color;
hidden = elementsTotal.get(keys[index]).hidden;
break;
case "work":
backgroundColor = elementsWork.get(keys[index]).color;
hidden = elementsWork.get(keys[index]).hidden;
break;
}
return {
label: data[0],
data: values,
borderWidth: 2,
// Set border color same as background except with less opacity
borderColor: `${backgroundColor}88`,
backgroundColor: backgroundColor,
hidden: hidden,
};
}),
};
};
const cleanStats = (mode) => {
const keys = Object.keys(localStorage).filter((key) => isValidKey(key));
const dataPoints = new Map(keys.map((key) => {
const [type, workName, statName, date] = key.split(DELIMITER);
return [
key,
{
type,
workName,
statName,
date: new Date(date),
},
];
}));
const toKeep = new Map();
const toDelete = new Set();
const findAndSortData = (value, key, shortDate) => {
const shortKey = getStatKey(value.type, value.workName, value.statName, shortDate);
const storedVal = toKeep.get(shortKey);
// If not stored, store it
if (!storedVal) {
toKeep.set(shortKey, {
fullDate: value.date,
key,
});
}
else if (value.date > storedVal.fullDate) {
// If current date is later than stored date
// Move stored item to delete
toDelete.add(storedVal.key);
// Set new stored item
toKeep.set(shortKey, {
fullDate: value.date,
key,
});
}
else if (value.date < storedVal.fullDate) {
// If current date is before stored date
// Set it to be deleted
toDelete.add(key);
}
};
if (mode === "hour") {
dataPoints.forEach((value, key) => {
findAndSortData(value, key, formatDate(value.date).yyyyMMdd_hh);
});
}
else if (mode === "day") {
dataPoints.forEach((value, key) => {
findAndSortData(value, key, formatDate(value.date).yyyyMMdd);
});
}
else if (mode === "month") {
dataPoints.forEach((value, key) => {
findAndSortData(value, key, formatDate(value.date).yyyyMM);
});
}
toDelete.forEach((item) => {
localStorage.removeItem(item);
});
drawChart(chartContainer, canvasTotal, "total");
};
const validateStatRow = (row, index) => {
if (row.type !== "total" && row.type !== "work") {
throw new Error(`Type "${row.type}" for row ${index} not recognized.`);
}
if (row.type !== "work" && row.workName !== "") {
throw new Error(`Work name "${row.workName}" was found for row ${index}, but the type is not "work"`);
}
else if (row.type === "work" && row.workName === "") {
throw new Error(`Type of row ${index} is "work", but work name is empty.`);
}
if (!Array.from(elementsTotal.keys()).includes(row.statName) &&
!Array.from(elementsWork.keys()).includes(row.statName)) {
throw new Error(`Stat name "${row.statName}" for row ${index} not recognized.`);
}
if (!new Date(row.date).getDate()) {
throw new Error(`Date "${row.date}" for row ${index} is invalid.`);
}
if (!row.value || Number(row.value) < 0) {
throw new Error(`Value "${row.value}" for row ${index} is invalid.`);
}
return true;
};
const plugin = {
id: "customCanvasBackgroundColor",
beforeDraw: (chart, _, options) => {
const { ctx: context } = chart;
context.save();
context.globalCompositeOperation = "destination-over";
context.fillStyle = options.color || "white";
context.fillRect(0, 0, chart.width, chart.height);
context.restore();
},
};
// Style buttons to match skin
const sheets = document.styleSheets;
for (const sheet of sheets) {
try {
const rules = sheet.cssRules;
for (const rule of rules) {
if (rule instanceof CSSStyleRule &&
rule.selectorText.includes("button:focus")) {
const newSelector = `${rule.selectorText}, #import-stats-input:focus+label`;
sheet.deleteRule([...rules].indexOf(rule));
sheet.insertRule(`${newSelector} { ${rule.style.cssText} }`, rules.length);
break;
}
}
for (const rule of rules) {
if (rule instanceof CSSStyleRule &&
rule.selectorText.includes(".actions label:hover")) {
const newSelector = `${rule.selectorText}, button:hover`;
sheet.deleteRule([...rules].indexOf(rule));
sheet.insertRule(`${newSelector} { ${rule.style.cssText} }`, rules.length);
break;
}
}
}
catch (e) {
//
}
}
})();