// 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 };