|
// retoor <retoor@molodetz.nl>
|
|
|
|
import { Http } from "./Http.js";
|
|
import { StatisticsCharts } from "./StatisticsCharts.js";
|
|
|
|
class StatisticsDashboard {
|
|
constructor(options = {}) {
|
|
this.root = document.getElementById(options.rootId || "statistics-root");
|
|
this.initialNode = document.getElementById(options.initialId || "statistics-initial");
|
|
this.windowSelect = document.getElementById("statistics-window");
|
|
this.compareToggle = document.getElementById("statistics-compare");
|
|
this.generated = document.querySelector("[data-meta='generated']");
|
|
this.tabLinks = Array.from(document.querySelectorAll(".admin-tabs .admin-tab[data-tab]"));
|
|
this.activeTab = options.activeTab || "overview";
|
|
this.windowHours = Number(options.windowHours || 168);
|
|
this.charts = new StatisticsCharts();
|
|
this.cache = new Map();
|
|
this.loading = false;
|
|
}
|
|
|
|
start() {
|
|
if (!this.root) return;
|
|
if (this.windowSelect) {
|
|
this.windowSelect.addEventListener("change", () => {
|
|
const parsed = parseInt(this.windowSelect.value, 10);
|
|
this.windowHours = Number.isNaN(parsed) ? 168 : parsed;
|
|
this.cache.clear();
|
|
this.loadTab(this.activeTab, true);
|
|
});
|
|
}
|
|
if (this.compareToggle) {
|
|
this.compareToggle.addEventListener("change", () => {
|
|
this.cache.clear();
|
|
this.loadTab(this.activeTab, true);
|
|
});
|
|
}
|
|
for (const link of this.tabLinks) {
|
|
link.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const tab = link.dataset.tab;
|
|
if (!tab || tab === this.activeTab) return;
|
|
for (const node of this.tabLinks) {
|
|
node.classList.toggle("active", node.dataset.tab === tab);
|
|
}
|
|
this.activeTab = tab;
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("tab", tab);
|
|
url.searchParams.set("hours", String(this.windowHours));
|
|
window.history.replaceState({}, "", url);
|
|
this.loadTab(tab, false);
|
|
});
|
|
}
|
|
const initial = this.readInitial();
|
|
if (initial) {
|
|
this.cache.set(this.cacheKey(this.activeTab), initial);
|
|
this.render(initial);
|
|
} else {
|
|
this.loadTab(this.activeTab, true);
|
|
}
|
|
}
|
|
|
|
readInitial() {
|
|
if (!this.initialNode) return null;
|
|
try {
|
|
return JSON.parse(this.initialNode.textContent || "null");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
cacheKey(tab) {
|
|
const compare = this.compareToggle && this.compareToggle.checked ? 1 : 0;
|
|
return `${tab}:${this.windowHours}:${compare}`;
|
|
}
|
|
|
|
async loadTab(tab, force) {
|
|
const key = this.cacheKey(tab);
|
|
if (!force && this.cache.has(key)) {
|
|
this.render(this.cache.get(key));
|
|
return;
|
|
}
|
|
if (this.loading) return;
|
|
this.loading = true;
|
|
this.root.innerHTML = '<p class="admin-empty">Loading statistics...</p>';
|
|
try {
|
|
const compare = this.compareToggle && this.compareToggle.checked ? 1 : 0;
|
|
const data = await Http.getJson(
|
|
`/admin/statistics/data?tab=${encodeURIComponent(tab)}&hours=${this.windowHours}&compare=${compare}`
|
|
);
|
|
this.cache.set(key, data);
|
|
this.render(data);
|
|
} catch {
|
|
this.root.innerHTML = '<p class="admin-empty">Could not load statistics.</p>';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
formatValue(card) {
|
|
const value = card.value;
|
|
const kind = card.format || "int";
|
|
if (kind === "money") {
|
|
const number = Number(value || 0);
|
|
return number < 1 ? `$${number.toFixed(4)}` : `$${number.toFixed(2)}`;
|
|
}
|
|
if (kind === "decimal") {
|
|
return Number(value || 0).toLocaleString("en-GB", { maximumFractionDigits: 1 });
|
|
}
|
|
return Number(value || 0).toLocaleString("en-GB");
|
|
}
|
|
|
|
deltaMarkup(card) {
|
|
if (card.delta === undefined || card.delta === null) return null;
|
|
const direction = card.direction || "flat";
|
|
const arrow = direction === "up" ? "\u2191" : direction === "down" ? "\u2193" : "\u2192";
|
|
const node = document.createElement("span");
|
|
node.className = `statistics-delta ${direction}`;
|
|
node.textContent = `${arrow} ${Math.abs(Number(card.delta || 0)).toLocaleString("en-GB")}%`;
|
|
return node;
|
|
}
|
|
|
|
isNumericValue(value) {
|
|
if (typeof value === "number" && Number.isFinite(value)) return true;
|
|
const text = String(value ?? "").trim().replace(/,/g, "");
|
|
if (!text) return false;
|
|
return /^-?\d+(\.\d+)?$/.test(text);
|
|
}
|
|
|
|
columnIsNumeric(columnIndex, columns, rows) {
|
|
const header = (columns[columnIndex] || "").toLowerCase();
|
|
const numericHeaders = [
|
|
"count", "events", "views", "gists", "posts", "votes", "actions", "stars",
|
|
"turns", "requests", "cost", "coins", "level", "restarts", "followers",
|
|
"upvotes", "downvotes", "members", "guests", "tokens", "errors",
|
|
];
|
|
if (numericHeaders.some((token) => header.includes(token))) return true;
|
|
const sample = rows.slice(0, 12);
|
|
if (!sample.length) return columnIndex > 0;
|
|
return sample.every((row) => this.isNumericValue(row[columnIndex]));
|
|
}
|
|
|
|
formatTableCell(value) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
if (Number.isInteger(value)) return value.toLocaleString("en-GB");
|
|
return value.toLocaleString("en-GB", { maximumFractionDigits: 2 });
|
|
}
|
|
const text = String(value ?? "");
|
|
const plain = text.trim().replace(/,/g, "");
|
|
if (/^-?\d+(\.\d+)?$/.test(plain)) {
|
|
const number = Number(plain);
|
|
if (Number.isInteger(number)) return number.toLocaleString("en-GB");
|
|
return number.toLocaleString("en-GB", { maximumFractionDigits: 2 });
|
|
}
|
|
return text;
|
|
}
|
|
|
|
columnClass(columnIndex, columns, rows) {
|
|
const header = (columns[columnIndex] || "").toLowerCase();
|
|
if (columnIndex === 0) {
|
|
if (
|
|
header.includes("username")
|
|
|| header === "user"
|
|
|| header.includes("instance")
|
|
|| header === "name"
|
|
) {
|
|
return "statistics-col-name";
|
|
}
|
|
return "statistics-col-key";
|
|
}
|
|
if (this.columnIsNumeric(columnIndex, columns, rows)) return "statistics-col-num";
|
|
return "statistics-col-text";
|
|
}
|
|
|
|
renderBreakdownTable(table) {
|
|
const columns = table.columns || [];
|
|
const rows = table.rows || [];
|
|
const panel = document.createElement("article");
|
|
panel.className = "statistics-table-panel";
|
|
if (columns.length > 2) panel.classList.add("is-wide");
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "statistics-table-panel-head";
|
|
const title = document.createElement("h4");
|
|
title.textContent = table.title || "";
|
|
head.appendChild(title);
|
|
const count = document.createElement("span");
|
|
count.className = "statistics-table-panel-count";
|
|
count.textContent = `${rows.length.toLocaleString("en-GB")} rows`;
|
|
head.appendChild(count);
|
|
panel.appendChild(head);
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "admin-table-wrap";
|
|
|
|
const node = document.createElement("table");
|
|
node.className = "admin-table";
|
|
|
|
const thead = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
for (let index = 0; index < columns.length; index += 1) {
|
|
const th = document.createElement("th");
|
|
th.className = this.columnClass(index, columns, rows);
|
|
th.textContent = columns[index];
|
|
headRow.appendChild(th);
|
|
}
|
|
thead.appendChild(headRow);
|
|
node.appendChild(thead);
|
|
|
|
const tbody = document.createElement("tbody");
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
for (let index = 0; index < columns.length; index += 1) {
|
|
const td = document.createElement("td");
|
|
const cellClass = this.columnClass(index, columns, rows);
|
|
td.className = cellClass;
|
|
const cell = row[index];
|
|
td.textContent = cellClass === "statistics-col-num"
|
|
? this.formatTableCell(cell)
|
|
: String(cell ?? "");
|
|
tr.appendChild(td);
|
|
}
|
|
tbody.appendChild(tr);
|
|
}
|
|
node.appendChild(tbody);
|
|
wrap.appendChild(node);
|
|
panel.appendChild(wrap);
|
|
return panel;
|
|
}
|
|
|
|
render(data) {
|
|
if (!data) return;
|
|
if (this.generated && data.generated_at) {
|
|
this.generated.textContent = `Generated ${data.generated_at.replace("T", " ").slice(0, 19)} UTC`;
|
|
}
|
|
this.root.innerHTML = "";
|
|
if (Array.isArray(data.highlights) && data.highlights.length) {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "metric-cards";
|
|
for (const item of data.highlights) {
|
|
const card = document.createElement("div");
|
|
card.className = "metric-card";
|
|
const value = document.createElement("span");
|
|
value.className = "metric-value";
|
|
value.textContent = String(item.value ?? "");
|
|
const label = document.createElement("span");
|
|
label.className = "metric-label";
|
|
label.textContent = item.label || "";
|
|
card.appendChild(value);
|
|
card.appendChild(label);
|
|
wrap.appendChild(card);
|
|
}
|
|
this.root.appendChild(wrap);
|
|
}
|
|
if (Array.isArray(data.cards) && data.cards.length) {
|
|
const grid = document.createElement("div");
|
|
grid.className = "metric-cards";
|
|
for (const card of data.cards) {
|
|
const node = document.createElement("div");
|
|
node.className = "metric-card";
|
|
const value = document.createElement("span");
|
|
value.className = "metric-value";
|
|
value.textContent = this.formatValue(card);
|
|
const label = document.createElement("span");
|
|
label.className = "metric-label";
|
|
label.textContent = card.label || "";
|
|
node.appendChild(value);
|
|
node.appendChild(label);
|
|
const delta = this.deltaMarkup(card);
|
|
if (delta) node.appendChild(delta);
|
|
grid.appendChild(node);
|
|
}
|
|
this.root.appendChild(grid);
|
|
}
|
|
if (Array.isArray(data.series) && data.series.length) {
|
|
const section = document.createElement("div");
|
|
section.className = "statistics-section";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = "Trends";
|
|
section.appendChild(heading);
|
|
const charts = document.createElement("div");
|
|
charts.className = "statistics-charts";
|
|
section.appendChild(charts);
|
|
this.root.appendChild(section);
|
|
const renderCharts = () => this.charts.render(charts, data.series);
|
|
if (window.Chart) {
|
|
renderCharts();
|
|
} else {
|
|
window.addEventListener("load", renderCharts, { once: true });
|
|
}
|
|
} else {
|
|
this.charts.destroy();
|
|
}
|
|
if (Array.isArray(data.tables) && data.tables.length) {
|
|
const section = document.createElement("div");
|
|
section.className = "statistics-section statistics-breakdowns";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = "Breakdowns";
|
|
section.appendChild(heading);
|
|
const grid = document.createElement("div");
|
|
grid.className = "statistics-breakdowns-grid";
|
|
for (const table of data.tables) {
|
|
grid.appendChild(this.renderBreakdownTable(table));
|
|
}
|
|
section.appendChild(grid);
|
|
this.root.appendChild(section);
|
|
}
|
|
if (data.notes && data.notes.full_page) {
|
|
const note = document.createElement("p");
|
|
note.className = "statistics-note";
|
|
const link = document.createElement("a");
|
|
link.className = "statistics-link";
|
|
link.href = data.notes.full_page;
|
|
link.textContent = "Open full detail page";
|
|
note.appendChild(link);
|
|
this.root.appendChild(note);
|
|
}
|
|
if (data.notes && data.notes.retention_days) {
|
|
const note = document.createElement("p");
|
|
note.className = "statistics-note";
|
|
note.textContent = `Visitor data retention: ${data.notes.retention_days} days.`;
|
|
this.root.appendChild(note);
|
|
}
|
|
}
|
|
}
|
|
|
|
window.StatisticsDashboard = StatisticsDashboard;
|
|
export { StatisticsDashboard }; |