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