WTT / static /app.js
loko99's picture
Upload 5 files
a65600d verified
Raw
History Blame Contribute Delete
16.9 kB
// ---------------------------------------------------------------
// Activity Time Explorer — fully client-side processing
// ---------------------------------------------------------------
const el = (id) => document.getElementById(id);
// ---- State ----
const state = {
rows: [], // parsed CSV rows (array of objects)
columns: [], // CSV header column names
filtered: [], // filtered subset
page: 1,
pageSize: 25,
sortBy: "Total Duration (s)",
sortDir: "desc",
debounceTimer: null,
};
// ---- Elements ----
const fileInput = el("fileInput");
const uploadTile = el("uploadTile");
const fileMeta = el("fileMeta");
const fileMetaName = el("fileMetaName");
const fileMetaRows = el("fileMetaRows");
const searchInput = el("searchInput");
const domainSelect = el("domainSelect");
const reasonSelect = el("reasonSelect");
const startDate = el("startDate");
const endDate = el("endDate");
const minDuration = el("minDuration");
const minDurationLabel = el("minDurationLabel");
const resetBtn = el("resetBtn");
const downloadBtn = el("downloadBtn");
const collapseBtn = el("collapseBtn");
const expandBtn = el("expandBtn");
const sidebar = el("sidebar");
const emptyState = el("emptyState");
const dashboard = el("dashboard");
const tabBtns = document.querySelectorAll(".tab-btn");
const tabDomain = el("tabDomain");
const tabUrl = el("tabUrl");
const toastEl = el("toast");
// ================ Utilities ================
function showToast(msg, isError = false) {
toastEl.textContent = msg;
toastEl.classList.toggle("error", isError);
toastEl.classList.remove("hidden");
setTimeout(() => toastEl.classList.add("hidden"), 3200);
}
function formatSeconds(sec) {
sec = Math.round(sec);
const m = Math.floor(sec / 60);
const s = sec % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
// ================ CSV Parsing ================
function parseCSV(text) {
const lines = text.split(/\r?\n/);
if (lines.length < 2) return { headers: [], rows: [] };
const headers = parseCSVLine(lines[0]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values = parseCSVLine(line);
const row = {};
headers.forEach((h, idx) => {
row[h] = values[idx] !== undefined ? values[idx] : "";
});
rows.push(row);
}
return { headers, rows };
}
function parseCSVLine(line) {
const result = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
result.push(current.trim());
current = "";
} else {
current += ch;
}
}
}
result.push(current.trim());
return result;
}
// ================ Data Processing ================
function processRows(rows) {
return rows.map((row) => {
// Numeric columns
for (const col of ["Total Duration (s)", "Idle Time (s)", "Active Time (s)"]) {
row[col] = parseFloat(row[col]) || 0;
}
// Ensure string columns
for (const col of ["Domain", "URL", "End Reason", "Start Time Local", "End Time Local"]) {
row[col] = row[col] || "";
}
// Extract date from Start Time (UTC)
if (row["Start Time (UTC)"]) {
const d = new Date(row["Start Time (UTC)"]);
row["Date"] = isNaN(d) ? "" : d.toISOString().slice(0, 10);
} else {
row["Date"] = "";
}
return row;
});
}
function getUniqueSorted(rows, key) {
const set = new Set();
rows.forEach((r) => {
const val = r[key];
if (val && val.trim()) set.add(val);
});
return Array.from(set).sort();
}
// ================ Sidebar Collapse ================
collapseBtn.addEventListener("click", () => {
sidebar.classList.add("collapsed");
expandBtn.classList.remove("hidden");
});
expandBtn.addEventListener("click", () => {
sidebar.classList.remove("collapsed");
expandBtn.classList.add("hidden");
});
// ================ File Upload ================
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const { headers, rows } = parseCSV(e.target.result);
if (!headers.length || !rows.length) {
showToast("CSV is empty or could not be parsed", true);
return;
}
state.columns = headers;
state.rows = processRows(rows);
onDataLoaded(file.name);
} catch (err) {
showToast("Error parsing CSV: " + err.message, true);
}
};
reader.onerror = () => showToast("Error reading file", true);
reader.readAsText(file);
});
function onDataLoaded(filename) {
const rows = state.rows;
fileMetaName.textContent = filename;
fileMetaRows.textContent = `${rows.length.toLocaleString()} rows`;
fileMeta.classList.remove("hidden");
// Populate domain select
domainSelect.innerHTML = "";
getUniqueSorted(rows, "Domain").forEach((d) => {
const opt = document.createElement("option");
opt.value = d;
opt.textContent = d;
domainSelect.appendChild(opt);
});
// Populate end reason select
reasonSelect.innerHTML = "";
getUniqueSorted(rows, "End Reason").forEach((r) => {
const opt = document.createElement("option");
opt.value = r;
opt.textContent = r;
reasonSelect.appendChild(opt);
});
// Date range
const dates = rows.map((r) => r.Date).filter(Boolean).sort();
if (dates.length) {
startDate.min = dates[0];
startDate.max = dates[dates.length - 1];
endDate.min = dates[0];
endDate.max = dates[dates.length - 1];
startDate.value = dates[0];
endDate.value = dates[dates.length - 1];
}
// Duration slider
const maxDur = Math.max(...rows.map((r) => r["Total Duration (s)"]), 1);
minDuration.min = 0;
minDuration.max = Math.ceil(maxDur);
minDuration.value = 0;
minDurationLabel.textContent = "0s";
// Enable controls
[searchInput, domainSelect, reasonSelect, startDate, endDate, minDuration, resetBtn, downloadBtn].forEach(
(elm) => (elm.disabled = false)
);
searchInput.value = "";
emptyState.classList.add("hidden");
dashboard.classList.remove("hidden");
showToast(`Loaded ${filename}`);
state.page = 1;
runFilter();
}
// ================ Filtering ================
function debounceFilter() {
clearTimeout(state.debounceTimer);
state.debounceTimer = setTimeout(() => {
state.page = 1;
runFilter();
}, 300);
}
searchInput.addEventListener("input", debounceFilter);
domainSelect.addEventListener("change", () => { state.page = 1; runFilter(); });
reasonSelect.addEventListener("change", () => { state.page = 1; runFilter(); });
startDate.addEventListener("change", () => { state.page = 1; runFilter(); });
endDate.addEventListener("change", () => { state.page = 1; runFilter(); });
minDuration.addEventListener("input", () => { minDurationLabel.textContent = `${minDuration.value}s`; });
minDuration.addEventListener("change", () => { state.page = 1; runFilter(); });
resetBtn.addEventListener("click", () => {
searchInput.value = "";
Array.from(domainSelect.options).forEach((o) => (o.selected = false));
Array.from(reasonSelect.options).forEach((o) => (o.selected = false));
startDate.value = startDate.min || "";
endDate.value = endDate.max || "";
minDuration.value = 0;
minDurationLabel.textContent = "0s";
state.page = 1;
runFilter();
});
function getSelectedValues(selectEl) {
return Array.from(selectEl.selectedOptions).map((o) => o.value);
}
function runFilter() {
if (!state.rows.length) return;
let filtered = state.rows;
// Search
const search = searchInput.value.trim().toLowerCase();
if (search) {
filtered = filtered.filter(
(r) =>
(r.Domain || "").toLowerCase().includes(search) ||
(r.URL || "").toLowerCase().includes(search)
);
}
// Domain filter
const domains = getSelectedValues(domainSelect);
if (domains.length) {
const set = new Set(domains);
filtered = filtered.filter((r) => set.has(r.Domain));
}
// End reason filter
const reasons = getSelectedValues(reasonSelect);
if (reasons.length) {
const set = new Set(reasons);
filtered = filtered.filter((r) => set.has(r["End Reason"]));
}
// Date range
const sd = startDate.value;
const ed = endDate.value;
if (sd && ed) {
filtered = filtered.filter((r) => r.Date >= sd && r.Date <= ed);
}
// Min duration
const minDur = Number(minDuration.value) || 0;
if (minDur > 0) {
filtered = filtered.filter((r) => r["Total Duration (s)"] >= minDur);
}
state.filtered = filtered;
renderSummary(filtered);
renderDomainChart(filtered);
renderUrlChart(filtered);
renderTable(filtered);
}
// ================ Summary ================
function renderSummary(filtered) {
const totalSec = filtered.reduce((s, r) => s + r["Total Duration (s)"], 0);
const uniqueDomains = new Set(filtered.map((r) => r.Domain)).size;
const uniqueUrls = new Set(filtered.map((r) => r.URL)).size;
el("metricEntries").textContent = filtered.length.toLocaleString();
el("metricTime").textContent = formatSeconds(totalSec);
el("metricDomains").textContent = uniqueDomains.toLocaleString();
el("metricUrls").textContent = uniqueUrls.toLocaleString();
}
// ================ Charts ================
function aggregateBy(rows, key, top = 25) {
const map = {};
rows.forEach((r) => {
const k = r[key] || "(empty)";
map[k] = (map[k] || 0) + r["Total Duration (s)"];
});
return Object.entries(map)
.sort((a, b) => b[1] - a[1])
.slice(0, top)
.map(([label, seconds]) => ({ label, seconds }));
}
function renderBarChart(container, items) {
container.innerHTML = "";
if (!items.length) return;
const maxVal = Math.max(...items.map((d) => d.seconds), 1);
items.forEach((item) => {
const row = document.createElement("div");
row.className = "bar-row";
const label = document.createElement("div");
label.className = "bar-label";
label.textContent = item.label;
label.title = item.label;
const track = document.createElement("div");
track.className = "bar-track";
const fill = document.createElement("div");
fill.className = "bar-fill";
fill.style.width = `${(item.seconds / maxVal) * 100}%`;
track.appendChild(fill);
const value = document.createElement("div");
value.className = "bar-value";
value.textContent = formatSeconds(item.seconds);
row.appendChild(label);
row.appendChild(track);
row.appendChild(value);
container.appendChild(row);
});
}
function renderDomainChart(filtered) {
const chartEl = el("domainChart");
const emptyEl = el("domainEmpty");
const items = aggregateBy(filtered, "Domain");
if (!items.length) {
chartEl.innerHTML = "";
emptyEl.classList.remove("hidden");
return;
}
emptyEl.classList.add("hidden");
renderBarChart(chartEl, items);
}
function renderUrlChart(filtered) {
const chartEl = el("urlChart");
const emptyEl = el("urlEmpty");
const items = aggregateBy(filtered, "URL");
if (!items.length) {
chartEl.innerHTML = "";
emptyEl.classList.remove("hidden");
return;
}
emptyEl.classList.add("hidden");
renderBarChart(chartEl, items);
}
// ================ Tabs ================
tabBtns.forEach((btn) => {
btn.addEventListener("click", () => {
tabBtns.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
if (btn.dataset.tab === "domain") {
tabDomain.classList.remove("hidden");
tabUrl.classList.add("hidden");
} else {
tabUrl.classList.remove("hidden");
tabDomain.classList.add("hidden");
}
});
});
// ================ Table ================
const displayCols = [
"Domain",
"URL",
"Start Time Local",
"End Time Local",
"Total Duration (s)",
"Idle Time (s)",
"Active Time (s)",
"End Reason",
];
function renderTable(filtered) {
const cols = displayCols.filter((c) => state.columns.includes(c));
// Sort
const sorted = [...filtered].sort((a, b) => {
let va = a[state.sortBy];
let vb = b[state.sortBy];
if (typeof va === "number" && typeof vb === "number") {
return state.sortDir === "asc" ? va - vb : vb - va;
}
va = String(va || "");
vb = String(vb || "");
return state.sortDir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
});
// Paginate
const totalRows = sorted.length;
const startIdx = (state.page - 1) * state.pageSize;
const pageRows = sorted.slice(startIdx, startIdx + state.pageSize);
// Header
const thead = el("tableHead");
thead.innerHTML = "";
const headRow = document.createElement("tr");
cols.forEach((col) => {
const th = document.createElement("th");
th.textContent = col;
if (state.sortBy === col) {
const indicator = document.createElement("span");
indicator.className = "sort-indicator";
indicator.textContent = state.sortDir === "asc" ? "▲" : "▼";
th.appendChild(indicator);
}
th.addEventListener("click", () => {
if (state.sortBy === col) {
state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
} else {
state.sortBy = col;
state.sortDir = "desc";
}
state.page = 1;
runFilter();
});
headRow.appendChild(th);
});
thead.appendChild(headRow);
// Body
const tbody = el("tableBody");
tbody.innerHTML = "";
if (!pageRows.length) {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = cols.length;
td.textContent = "No data matches the current filters.";
td.style.textAlign = "center";
td.style.color = "var(--text-muted)";
td.style.padding = "24px";
tr.appendChild(td);
tbody.appendChild(tr);
} else {
pageRows.forEach((row) => {
const tr = document.createElement("tr");
cols.forEach((col) => {
const td = document.createElement("td");
let val = row[col];
if (typeof val === "number" && col.includes("(s)")) val = val.toFixed(1);
td.textContent = val === null || val === undefined ? "" : val;
td.title = td.textContent;
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
renderPagination(totalRows);
}
function renderPagination(totalRows) {
const container = el("pagination");
container.innerHTML = "";
const totalPages = Math.max(Math.ceil(totalRows / state.pageSize), 1);
const info = document.createElement("span");
const startIdx = totalRows === 0 ? 0 : (state.page - 1) * state.pageSize + 1;
const endIdx = Math.min(state.page * state.pageSize, totalRows);
info.textContent = `${startIdx}${endIdx} of ${totalRows.toLocaleString()}`;
const prevBtn = document.createElement("button");
prevBtn.textContent = "← Prev";
prevBtn.disabled = state.page <= 1;
prevBtn.addEventListener("click", () => { state.page--; runFilter(); });
const nextBtn = document.createElement("button");
nextBtn.textContent = "Next →";
nextBtn.disabled = state.page >= totalPages;
nextBtn.addEventListener("click", () => { state.page++; runFilter(); });
container.appendChild(info);
container.appendChild(prevBtn);
container.appendChild(nextBtn);
}
// ================ Download ================
downloadBtn.addEventListener("click", () => {
if (!state.filtered.length) return;
const cols = displayCols.filter((c) => state.columns.includes(c));
// Sort same as table
const sorted = [...state.filtered].sort((a, b) => {
let va = a[state.sortBy];
let vb = b[state.sortBy];
if (typeof va === "number" && typeof vb === "number") {
return state.sortDir === "asc" ? va - vb : vb - va;
}
va = String(va || "");
vb = String(vb || "");
return state.sortDir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
});
// Build CSV
const escapeCSV = (v) => {
const s = String(v ?? "");
return s.includes(",") || s.includes('"') || s.includes("\n")
? '"' + s.replace(/"/g, '""') + '"'
: s;
};
const lines = [cols.map(escapeCSV).join(",")];
sorted.forEach((row) => {
lines.push(cols.map((c) => escapeCSV(row[c])).join(","));
});
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "filtered_activity.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});