| |
| |
| |
|
|
| const el = (id) => document.getElementById(id); |
|
|
| |
| const state = { |
| rows: [], |
| columns: [], |
| filtered: [], |
| page: 1, |
| pageSize: 25, |
| sortBy: "Total Duration (s)", |
| sortDir: "desc", |
| debounceTimer: null, |
| }; |
|
|
| |
| 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"); |
|
|
| |
|
|
| 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`; |
| } |
|
|
| |
|
|
| 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; |
| } |
|
|
| |
|
|
| function processRows(rows) { |
| return rows.map((row) => { |
| |
| for (const col of ["Total Duration (s)", "Idle Time (s)", "Active Time (s)"]) { |
| row[col] = parseFloat(row[col]) || 0; |
| } |
|
|
| |
| for (const col of ["Domain", "URL", "End Reason", "Start Time Local", "End Time Local"]) { |
| row[col] = row[col] || ""; |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
|
|
| collapseBtn.addEventListener("click", () => { |
| sidebar.classList.add("collapsed"); |
| expandBtn.classList.remove("hidden"); |
| }); |
| expandBtn.addEventListener("click", () => { |
| sidebar.classList.remove("collapsed"); |
| expandBtn.classList.add("hidden"); |
| }); |
|
|
| |
|
|
| 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"); |
|
|
| |
| domainSelect.innerHTML = ""; |
| getUniqueSorted(rows, "Domain").forEach((d) => { |
| const opt = document.createElement("option"); |
| opt.value = d; |
| opt.textContent = d; |
| domainSelect.appendChild(opt); |
| }); |
|
|
| |
| reasonSelect.innerHTML = ""; |
| getUniqueSorted(rows, "End Reason").forEach((r) => { |
| const opt = document.createElement("option"); |
| opt.value = r; |
| opt.textContent = r; |
| reasonSelect.appendChild(opt); |
| }); |
|
|
| |
| 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]; |
| } |
|
|
| |
| 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"; |
|
|
| |
| [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(); |
| } |
|
|
| |
|
|
| 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; |
|
|
| |
| const search = searchInput.value.trim().toLowerCase(); |
| if (search) { |
| filtered = filtered.filter( |
| (r) => |
| (r.Domain || "").toLowerCase().includes(search) || |
| (r.URL || "").toLowerCase().includes(search) |
| ); |
| } |
|
|
| |
| const domains = getSelectedValues(domainSelect); |
| if (domains.length) { |
| const set = new Set(domains); |
| filtered = filtered.filter((r) => set.has(r.Domain)); |
| } |
|
|
| |
| const reasons = getSelectedValues(reasonSelect); |
| if (reasons.length) { |
| const set = new Set(reasons); |
| filtered = filtered.filter((r) => set.has(r["End Reason"])); |
| } |
|
|
| |
| const sd = startDate.value; |
| const ed = endDate.value; |
| if (sd && ed) { |
| filtered = filtered.filter((r) => r.Date >= sd && r.Date <= ed); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
|
|
| 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(); |
| } |
|
|
| |
|
|
| 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); |
| } |
|
|
| |
|
|
| 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"); |
| } |
| }); |
| }); |
|
|
| |
|
|
| 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)); |
|
|
| |
| 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); |
| }); |
|
|
| |
| const totalRows = sorted.length; |
| const startIdx = (state.page - 1) * state.pageSize; |
| const pageRows = sorted.slice(startIdx, startIdx + state.pageSize); |
|
|
| |
| 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); |
|
|
| |
| 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); |
| } |
|
|
| |
|
|
| downloadBtn.addEventListener("click", () => { |
| if (!state.filtered.length) return; |
|
|
| const cols = displayCols.filter((c) => state.columns.includes(c)); |
|
|
| |
| 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); |
| }); |
|
|
| |
| 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); |
| }); |
|
|