loko99 commited on
Commit
a65600d
·
verified ·
1 Parent(s): 229e204

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +11 -0
  2. app.py +27 -0
  3. static/app.js +592 -0
  4. static/style.css +669 -0
  5. templates/index.html +156 -0
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN pip install --no-cache-dir flask waitress
6
+
7
+ COPY . .
8
+
9
+ EXPOSE 5000
10
+
11
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Activity Time Explorer (Flask)
3
+ -------------------------------
4
+ Minimal Flask server — serves only the HTML template and static assets.
5
+ All CSV processing happens client-side in the browser.
6
+
7
+ Run with:
8
+ pip install flask
9
+ python app.py
10
+
11
+ Then open http://localhost:5000 and upload a CSV.
12
+ """
13
+
14
+ from flask import Flask, render_template
15
+ from waitress import serve
16
+
17
+ app = Flask(__name__)
18
+
19
+
20
+ @app.route("/")
21
+ def index():
22
+ return render_template("index.html")
23
+
24
+
25
+ if __name__ == "__main__":
26
+ print("Serving on http://localhost:5000")
27
+ serve(app, host="0.0.0.0", port=5000)
static/app.js ADDED
@@ -0,0 +1,592 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------
2
+ // Activity Time Explorer — fully client-side processing
3
+ // ---------------------------------------------------------------
4
+
5
+ const el = (id) => document.getElementById(id);
6
+
7
+ // ---- State ----
8
+ const state = {
9
+ rows: [], // parsed CSV rows (array of objects)
10
+ columns: [], // CSV header column names
11
+ filtered: [], // filtered subset
12
+ page: 1,
13
+ pageSize: 25,
14
+ sortBy: "Total Duration (s)",
15
+ sortDir: "desc",
16
+ debounceTimer: null,
17
+ };
18
+
19
+ // ---- Elements ----
20
+ const fileInput = el("fileInput");
21
+ const uploadTile = el("uploadTile");
22
+ const fileMeta = el("fileMeta");
23
+ const fileMetaName = el("fileMetaName");
24
+ const fileMetaRows = el("fileMetaRows");
25
+
26
+ const searchInput = el("searchInput");
27
+ const domainSelect = el("domainSelect");
28
+ const reasonSelect = el("reasonSelect");
29
+ const startDate = el("startDate");
30
+ const endDate = el("endDate");
31
+ const minDuration = el("minDuration");
32
+ const minDurationLabel = el("minDurationLabel");
33
+ const resetBtn = el("resetBtn");
34
+ const downloadBtn = el("downloadBtn");
35
+
36
+ const collapseBtn = el("collapseBtn");
37
+ const expandBtn = el("expandBtn");
38
+ const sidebar = el("sidebar");
39
+
40
+ const emptyState = el("emptyState");
41
+ const dashboard = el("dashboard");
42
+
43
+ const tabBtns = document.querySelectorAll(".tab-btn");
44
+ const tabDomain = el("tabDomain");
45
+ const tabUrl = el("tabUrl");
46
+
47
+ const toastEl = el("toast");
48
+
49
+ // ================ Utilities ================
50
+
51
+ function showToast(msg, isError = false) {
52
+ toastEl.textContent = msg;
53
+ toastEl.classList.toggle("error", isError);
54
+ toastEl.classList.remove("hidden");
55
+ setTimeout(() => toastEl.classList.add("hidden"), 3200);
56
+ }
57
+
58
+ function formatSeconds(sec) {
59
+ sec = Math.round(sec);
60
+ const m = Math.floor(sec / 60);
61
+ const s = sec % 60;
62
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
63
+ }
64
+
65
+ // ================ CSV Parsing ================
66
+
67
+ function parseCSV(text) {
68
+ const lines = text.split(/\r?\n/);
69
+ if (lines.length < 2) return { headers: [], rows: [] };
70
+
71
+ const headers = parseCSVLine(lines[0]);
72
+ const rows = [];
73
+
74
+ for (let i = 1; i < lines.length; i++) {
75
+ const line = lines[i].trim();
76
+ if (!line) continue;
77
+ const values = parseCSVLine(line);
78
+ const row = {};
79
+ headers.forEach((h, idx) => {
80
+ row[h] = values[idx] !== undefined ? values[idx] : "";
81
+ });
82
+ rows.push(row);
83
+ }
84
+
85
+ return { headers, rows };
86
+ }
87
+
88
+ function parseCSVLine(line) {
89
+ const result = [];
90
+ let current = "";
91
+ let inQuotes = false;
92
+
93
+ for (let i = 0; i < line.length; i++) {
94
+ const ch = line[i];
95
+ if (inQuotes) {
96
+ if (ch === '"') {
97
+ if (i + 1 < line.length && line[i + 1] === '"') {
98
+ current += '"';
99
+ i++;
100
+ } else {
101
+ inQuotes = false;
102
+ }
103
+ } else {
104
+ current += ch;
105
+ }
106
+ } else {
107
+ if (ch === '"') {
108
+ inQuotes = true;
109
+ } else if (ch === ",") {
110
+ result.push(current.trim());
111
+ current = "";
112
+ } else {
113
+ current += ch;
114
+ }
115
+ }
116
+ }
117
+ result.push(current.trim());
118
+ return result;
119
+ }
120
+
121
+ // ================ Data Processing ================
122
+
123
+ function processRows(rows) {
124
+ return rows.map((row) => {
125
+ // Numeric columns
126
+ for (const col of ["Total Duration (s)", "Idle Time (s)", "Active Time (s)"]) {
127
+ row[col] = parseFloat(row[col]) || 0;
128
+ }
129
+
130
+ // Ensure string columns
131
+ for (const col of ["Domain", "URL", "End Reason", "Start Time Local", "End Time Local"]) {
132
+ row[col] = row[col] || "";
133
+ }
134
+
135
+ // Extract date from Start Time (UTC)
136
+ if (row["Start Time (UTC)"]) {
137
+ const d = new Date(row["Start Time (UTC)"]);
138
+ row["Date"] = isNaN(d) ? "" : d.toISOString().slice(0, 10);
139
+ } else {
140
+ row["Date"] = "";
141
+ }
142
+
143
+ return row;
144
+ });
145
+ }
146
+
147
+ function getUniqueSorted(rows, key) {
148
+ const set = new Set();
149
+ rows.forEach((r) => {
150
+ const val = r[key];
151
+ if (val && val.trim()) set.add(val);
152
+ });
153
+ return Array.from(set).sort();
154
+ }
155
+
156
+ // ================ Sidebar Collapse ================
157
+
158
+ collapseBtn.addEventListener("click", () => {
159
+ sidebar.classList.add("collapsed");
160
+ expandBtn.classList.remove("hidden");
161
+ });
162
+ expandBtn.addEventListener("click", () => {
163
+ sidebar.classList.remove("collapsed");
164
+ expandBtn.classList.add("hidden");
165
+ });
166
+
167
+ // ================ File Upload ================
168
+
169
+ fileInput.addEventListener("change", () => {
170
+ const file = fileInput.files[0];
171
+ if (!file) return;
172
+
173
+ const reader = new FileReader();
174
+ reader.onload = (e) => {
175
+ try {
176
+ const { headers, rows } = parseCSV(e.target.result);
177
+ if (!headers.length || !rows.length) {
178
+ showToast("CSV is empty or could not be parsed", true);
179
+ return;
180
+ }
181
+ state.columns = headers;
182
+ state.rows = processRows(rows);
183
+ onDataLoaded(file.name);
184
+ } catch (err) {
185
+ showToast("Error parsing CSV: " + err.message, true);
186
+ }
187
+ };
188
+ reader.onerror = () => showToast("Error reading file", true);
189
+ reader.readAsText(file);
190
+ });
191
+
192
+ function onDataLoaded(filename) {
193
+ const rows = state.rows;
194
+
195
+ fileMetaName.textContent = filename;
196
+ fileMetaRows.textContent = `${rows.length.toLocaleString()} rows`;
197
+ fileMeta.classList.remove("hidden");
198
+
199
+ // Populate domain select
200
+ domainSelect.innerHTML = "";
201
+ getUniqueSorted(rows, "Domain").forEach((d) => {
202
+ const opt = document.createElement("option");
203
+ opt.value = d;
204
+ opt.textContent = d;
205
+ domainSelect.appendChild(opt);
206
+ });
207
+
208
+ // Populate end reason select
209
+ reasonSelect.innerHTML = "";
210
+ getUniqueSorted(rows, "End Reason").forEach((r) => {
211
+ const opt = document.createElement("option");
212
+ opt.value = r;
213
+ opt.textContent = r;
214
+ reasonSelect.appendChild(opt);
215
+ });
216
+
217
+ // Date range
218
+ const dates = rows.map((r) => r.Date).filter(Boolean).sort();
219
+ if (dates.length) {
220
+ startDate.min = dates[0];
221
+ startDate.max = dates[dates.length - 1];
222
+ endDate.min = dates[0];
223
+ endDate.max = dates[dates.length - 1];
224
+ startDate.value = dates[0];
225
+ endDate.value = dates[dates.length - 1];
226
+ }
227
+
228
+ // Duration slider
229
+ const maxDur = Math.max(...rows.map((r) => r["Total Duration (s)"]), 1);
230
+ minDuration.min = 0;
231
+ minDuration.max = Math.ceil(maxDur);
232
+ minDuration.value = 0;
233
+ minDurationLabel.textContent = "0s";
234
+
235
+ // Enable controls
236
+ [searchInput, domainSelect, reasonSelect, startDate, endDate, minDuration, resetBtn, downloadBtn].forEach(
237
+ (elm) => (elm.disabled = false)
238
+ );
239
+ searchInput.value = "";
240
+
241
+ emptyState.classList.add("hidden");
242
+ dashboard.classList.remove("hidden");
243
+
244
+ showToast(`Loaded ${filename}`);
245
+ state.page = 1;
246
+ runFilter();
247
+ }
248
+
249
+ // ================ Filtering ================
250
+
251
+ function debounceFilter() {
252
+ clearTimeout(state.debounceTimer);
253
+ state.debounceTimer = setTimeout(() => {
254
+ state.page = 1;
255
+ runFilter();
256
+ }, 300);
257
+ }
258
+
259
+ searchInput.addEventListener("input", debounceFilter);
260
+ domainSelect.addEventListener("change", () => { state.page = 1; runFilter(); });
261
+ reasonSelect.addEventListener("change", () => { state.page = 1; runFilter(); });
262
+ startDate.addEventListener("change", () => { state.page = 1; runFilter(); });
263
+ endDate.addEventListener("change", () => { state.page = 1; runFilter(); });
264
+ minDuration.addEventListener("input", () => { minDurationLabel.textContent = `${minDuration.value}s`; });
265
+ minDuration.addEventListener("change", () => { state.page = 1; runFilter(); });
266
+
267
+ resetBtn.addEventListener("click", () => {
268
+ searchInput.value = "";
269
+ Array.from(domainSelect.options).forEach((o) => (o.selected = false));
270
+ Array.from(reasonSelect.options).forEach((o) => (o.selected = false));
271
+ startDate.value = startDate.min || "";
272
+ endDate.value = endDate.max || "";
273
+ minDuration.value = 0;
274
+ minDurationLabel.textContent = "0s";
275
+ state.page = 1;
276
+ runFilter();
277
+ });
278
+
279
+ function getSelectedValues(selectEl) {
280
+ return Array.from(selectEl.selectedOptions).map((o) => o.value);
281
+ }
282
+
283
+ function runFilter() {
284
+ if (!state.rows.length) return;
285
+
286
+ let filtered = state.rows;
287
+
288
+ // Search
289
+ const search = searchInput.value.trim().toLowerCase();
290
+ if (search) {
291
+ filtered = filtered.filter(
292
+ (r) =>
293
+ (r.Domain || "").toLowerCase().includes(search) ||
294
+ (r.URL || "").toLowerCase().includes(search)
295
+ );
296
+ }
297
+
298
+ // Domain filter
299
+ const domains = getSelectedValues(domainSelect);
300
+ if (domains.length) {
301
+ const set = new Set(domains);
302
+ filtered = filtered.filter((r) => set.has(r.Domain));
303
+ }
304
+
305
+ // End reason filter
306
+ const reasons = getSelectedValues(reasonSelect);
307
+ if (reasons.length) {
308
+ const set = new Set(reasons);
309
+ filtered = filtered.filter((r) => set.has(r["End Reason"]));
310
+ }
311
+
312
+ // Date range
313
+ const sd = startDate.value;
314
+ const ed = endDate.value;
315
+ if (sd && ed) {
316
+ filtered = filtered.filter((r) => r.Date >= sd && r.Date <= ed);
317
+ }
318
+
319
+ // Min duration
320
+ const minDur = Number(minDuration.value) || 0;
321
+ if (minDur > 0) {
322
+ filtered = filtered.filter((r) => r["Total Duration (s)"] >= minDur);
323
+ }
324
+
325
+ state.filtered = filtered;
326
+
327
+ renderSummary(filtered);
328
+ renderDomainChart(filtered);
329
+ renderUrlChart(filtered);
330
+ renderTable(filtered);
331
+ }
332
+
333
+ // ================ Summary ================
334
+
335
+ function renderSummary(filtered) {
336
+ const totalSec = filtered.reduce((s, r) => s + r["Total Duration (s)"], 0);
337
+ const uniqueDomains = new Set(filtered.map((r) => r.Domain)).size;
338
+ const uniqueUrls = new Set(filtered.map((r) => r.URL)).size;
339
+
340
+ el("metricEntries").textContent = filtered.length.toLocaleString();
341
+ el("metricTime").textContent = formatSeconds(totalSec);
342
+ el("metricDomains").textContent = uniqueDomains.toLocaleString();
343
+ el("metricUrls").textContent = uniqueUrls.toLocaleString();
344
+ }
345
+
346
+ // ================ Charts ================
347
+
348
+ function aggregateBy(rows, key, top = 25) {
349
+ const map = {};
350
+ rows.forEach((r) => {
351
+ const k = r[key] || "(empty)";
352
+ map[k] = (map[k] || 0) + r["Total Duration (s)"];
353
+ });
354
+ return Object.entries(map)
355
+ .sort((a, b) => b[1] - a[1])
356
+ .slice(0, top)
357
+ .map(([label, seconds]) => ({ label, seconds }));
358
+ }
359
+
360
+ function renderBarChart(container, items) {
361
+ container.innerHTML = "";
362
+ if (!items.length) return;
363
+
364
+ const maxVal = Math.max(...items.map((d) => d.seconds), 1);
365
+
366
+ items.forEach((item) => {
367
+ const row = document.createElement("div");
368
+ row.className = "bar-row";
369
+
370
+ const label = document.createElement("div");
371
+ label.className = "bar-label";
372
+ label.textContent = item.label;
373
+ label.title = item.label;
374
+
375
+ const track = document.createElement("div");
376
+ track.className = "bar-track";
377
+ const fill = document.createElement("div");
378
+ fill.className = "bar-fill";
379
+ fill.style.width = `${(item.seconds / maxVal) * 100}%`;
380
+ track.appendChild(fill);
381
+
382
+ const value = document.createElement("div");
383
+ value.className = "bar-value";
384
+ value.textContent = formatSeconds(item.seconds);
385
+
386
+ row.appendChild(label);
387
+ row.appendChild(track);
388
+ row.appendChild(value);
389
+ container.appendChild(row);
390
+ });
391
+ }
392
+
393
+ function renderDomainChart(filtered) {
394
+ const chartEl = el("domainChart");
395
+ const emptyEl = el("domainEmpty");
396
+ const items = aggregateBy(filtered, "Domain");
397
+ if (!items.length) {
398
+ chartEl.innerHTML = "";
399
+ emptyEl.classList.remove("hidden");
400
+ return;
401
+ }
402
+ emptyEl.classList.add("hidden");
403
+ renderBarChart(chartEl, items);
404
+ }
405
+
406
+ function renderUrlChart(filtered) {
407
+ const chartEl = el("urlChart");
408
+ const emptyEl = el("urlEmpty");
409
+ const items = aggregateBy(filtered, "URL");
410
+ if (!items.length) {
411
+ chartEl.innerHTML = "";
412
+ emptyEl.classList.remove("hidden");
413
+ return;
414
+ }
415
+ emptyEl.classList.add("hidden");
416
+ renderBarChart(chartEl, items);
417
+ }
418
+
419
+ // ================ Tabs ================
420
+
421
+ tabBtns.forEach((btn) => {
422
+ btn.addEventListener("click", () => {
423
+ tabBtns.forEach((b) => b.classList.remove("active"));
424
+ btn.classList.add("active");
425
+ if (btn.dataset.tab === "domain") {
426
+ tabDomain.classList.remove("hidden");
427
+ tabUrl.classList.add("hidden");
428
+ } else {
429
+ tabUrl.classList.remove("hidden");
430
+ tabDomain.classList.add("hidden");
431
+ }
432
+ });
433
+ });
434
+
435
+ // ================ Table ================
436
+
437
+ const displayCols = [
438
+ "Domain",
439
+ "URL",
440
+ "Start Time Local",
441
+ "End Time Local",
442
+ "Total Duration (s)",
443
+ "Idle Time (s)",
444
+ "Active Time (s)",
445
+ "End Reason",
446
+ ];
447
+
448
+ function renderTable(filtered) {
449
+ const cols = displayCols.filter((c) => state.columns.includes(c));
450
+
451
+ // Sort
452
+ const sorted = [...filtered].sort((a, b) => {
453
+ let va = a[state.sortBy];
454
+ let vb = b[state.sortBy];
455
+ if (typeof va === "number" && typeof vb === "number") {
456
+ return state.sortDir === "asc" ? va - vb : vb - va;
457
+ }
458
+ va = String(va || "");
459
+ vb = String(vb || "");
460
+ return state.sortDir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
461
+ });
462
+
463
+ // Paginate
464
+ const totalRows = sorted.length;
465
+ const startIdx = (state.page - 1) * state.pageSize;
466
+ const pageRows = sorted.slice(startIdx, startIdx + state.pageSize);
467
+
468
+ // Header
469
+ const thead = el("tableHead");
470
+ thead.innerHTML = "";
471
+ const headRow = document.createElement("tr");
472
+ cols.forEach((col) => {
473
+ const th = document.createElement("th");
474
+ th.textContent = col;
475
+ if (state.sortBy === col) {
476
+ const indicator = document.createElement("span");
477
+ indicator.className = "sort-indicator";
478
+ indicator.textContent = state.sortDir === "asc" ? "▲" : "▼";
479
+ th.appendChild(indicator);
480
+ }
481
+ th.addEventListener("click", () => {
482
+ if (state.sortBy === col) {
483
+ state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
484
+ } else {
485
+ state.sortBy = col;
486
+ state.sortDir = "desc";
487
+ }
488
+ state.page = 1;
489
+ runFilter();
490
+ });
491
+ headRow.appendChild(th);
492
+ });
493
+ thead.appendChild(headRow);
494
+
495
+ // Body
496
+ const tbody = el("tableBody");
497
+ tbody.innerHTML = "";
498
+ if (!pageRows.length) {
499
+ const tr = document.createElement("tr");
500
+ const td = document.createElement("td");
501
+ td.colSpan = cols.length;
502
+ td.textContent = "No data matches the current filters.";
503
+ td.style.textAlign = "center";
504
+ td.style.color = "var(--text-muted)";
505
+ td.style.padding = "24px";
506
+ tr.appendChild(td);
507
+ tbody.appendChild(tr);
508
+ } else {
509
+ pageRows.forEach((row) => {
510
+ const tr = document.createElement("tr");
511
+ cols.forEach((col) => {
512
+ const td = document.createElement("td");
513
+ let val = row[col];
514
+ if (typeof val === "number" && col.includes("(s)")) val = val.toFixed(1);
515
+ td.textContent = val === null || val === undefined ? "" : val;
516
+ td.title = td.textContent;
517
+ tr.appendChild(td);
518
+ });
519
+ tbody.appendChild(tr);
520
+ });
521
+ }
522
+
523
+ renderPagination(totalRows);
524
+ }
525
+
526
+ function renderPagination(totalRows) {
527
+ const container = el("pagination");
528
+ container.innerHTML = "";
529
+ const totalPages = Math.max(Math.ceil(totalRows / state.pageSize), 1);
530
+
531
+ const info = document.createElement("span");
532
+ const startIdx = totalRows === 0 ? 0 : (state.page - 1) * state.pageSize + 1;
533
+ const endIdx = Math.min(state.page * state.pageSize, totalRows);
534
+ info.textContent = `${startIdx}–${endIdx} of ${totalRows.toLocaleString()}`;
535
+
536
+ const prevBtn = document.createElement("button");
537
+ prevBtn.textContent = "← Prev";
538
+ prevBtn.disabled = state.page <= 1;
539
+ prevBtn.addEventListener("click", () => { state.page--; runFilter(); });
540
+
541
+ const nextBtn = document.createElement("button");
542
+ nextBtn.textContent = "Next →";
543
+ nextBtn.disabled = state.page >= totalPages;
544
+ nextBtn.addEventListener("click", () => { state.page++; runFilter(); });
545
+
546
+ container.appendChild(info);
547
+ container.appendChild(prevBtn);
548
+ container.appendChild(nextBtn);
549
+ }
550
+
551
+ // ================ Download ================
552
+
553
+ downloadBtn.addEventListener("click", () => {
554
+ if (!state.filtered.length) return;
555
+
556
+ const cols = displayCols.filter((c) => state.columns.includes(c));
557
+
558
+ // Sort same as table
559
+ const sorted = [...state.filtered].sort((a, b) => {
560
+ let va = a[state.sortBy];
561
+ let vb = b[state.sortBy];
562
+ if (typeof va === "number" && typeof vb === "number") {
563
+ return state.sortDir === "asc" ? va - vb : vb - va;
564
+ }
565
+ va = String(va || "");
566
+ vb = String(vb || "");
567
+ return state.sortDir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
568
+ });
569
+
570
+ // Build CSV
571
+ const escapeCSV = (v) => {
572
+ const s = String(v ?? "");
573
+ return s.includes(",") || s.includes('"') || s.includes("\n")
574
+ ? '"' + s.replace(/"/g, '""') + '"'
575
+ : s;
576
+ };
577
+
578
+ const lines = [cols.map(escapeCSV).join(",")];
579
+ sorted.forEach((row) => {
580
+ lines.push(cols.map((c) => escapeCSV(row[c])).join(","));
581
+ });
582
+
583
+ const blob = new Blob([lines.join("\n")], { type: "text/csv" });
584
+ const url = URL.createObjectURL(blob);
585
+ const a = document.createElement("a");
586
+ a.href = url;
587
+ a.download = "filtered_activity.csv";
588
+ document.body.appendChild(a);
589
+ a.click();
590
+ document.body.removeChild(a);
591
+ URL.revokeObjectURL(url);
592
+ });
static/style.css ADDED
@@ -0,0 +1,669 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ---------------------------------------------------------------
2
+ Activity Time Explorer — Minimalistic dark-first theme
3
+ System theme detection via prefers-color-scheme
4
+ --------------------------------------------------------------- */
5
+
6
+ /* ---- Dark mode (default) ---- */
7
+ :root {
8
+ --bg-main: #111111;
9
+ --bg-sidebar: #161616;
10
+ --bg-card: #1a1a1a;
11
+ --bg-hover: #252525;
12
+ --bg-input: #1e1e1e;
13
+ --border: #2a2a2a;
14
+ --border-subtle: #222222;
15
+ --text-primary: #e4e4e7;
16
+ --text-secondary: #9a9aa0;
17
+ --text-muted: #636368;
18
+ --accent: #2dd4a8;
19
+ --accent-hover: #22b893;
20
+ --accent-dim: rgba(45, 212, 168, 0.12);
21
+ --danger: #ef4444;
22
+ --bar-track: #222222;
23
+ --radius: 8px;
24
+ --sidebar-width: 260px;
25
+ --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
26
+ color-scheme: dark;
27
+ }
28
+
29
+ /* ---- Light mode (follows system) ---- */
30
+ @media (prefers-color-scheme: light) {
31
+ :root {
32
+ --bg-main: #fafafa;
33
+ --bg-sidebar: #f3f3f3;
34
+ --bg-card: #ffffff;
35
+ --bg-hover: #ebebeb;
36
+ --bg-input: #ffffff;
37
+ --border: #e2e2e2;
38
+ --border-subtle: #eeeeee;
39
+ --text-primary: #1a1a1a;
40
+ --text-secondary: #6b6b74;
41
+ --text-muted: #9a9aa0;
42
+ --accent: #10a37f;
43
+ --accent-hover: #0d8a6a;
44
+ --accent-dim: rgba(16, 163, 127, 0.08);
45
+ --danger: #d1453b;
46
+ --bar-track: #f0f0f0;
47
+ color-scheme: light;
48
+ }
49
+ }
50
+
51
+ /* ---- Reset ---- */
52
+ * { box-sizing: border-box; margin: 0; padding: 0; }
53
+
54
+ html, body {
55
+ height: 100%;
56
+ font-family: var(--font);
57
+ color: var(--text-primary);
58
+ background: var(--bg-main);
59
+ font-size: 13.5px;
60
+ -webkit-font-smoothing: antialiased;
61
+ }
62
+
63
+ button, input, select, textarea {
64
+ font-family: inherit;
65
+ font-size: inherit;
66
+ color: inherit;
67
+ }
68
+
69
+ .hidden { display: none !important; }
70
+ .muted { color: var(--text-muted); font-weight: 400; }
71
+
72
+ /* ================ App Shell ================ */
73
+
74
+ .app {
75
+ display: flex;
76
+ height: 100vh;
77
+ overflow: hidden;
78
+ }
79
+
80
+ /* ================ Sidebar ================ */
81
+
82
+ .sidebar {
83
+ width: var(--sidebar-width);
84
+ min-width: var(--sidebar-width);
85
+ background: var(--bg-sidebar);
86
+ border-right: 1px solid var(--border);
87
+ display: flex;
88
+ flex-direction: column;
89
+ padding: 14px 12px;
90
+ overflow-y: auto;
91
+ transition: margin-left 0.2s ease;
92
+ }
93
+
94
+ .sidebar.collapsed {
95
+ margin-left: calc(-1 * var(--sidebar-width));
96
+ }
97
+
98
+ .sidebar-header {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ padding: 4px 2px 16px 2px;
103
+ }
104
+
105
+ .brand {
106
+ font-weight: 600;
107
+ font-size: 14px;
108
+ letter-spacing: -0.01em;
109
+ }
110
+
111
+ .icon-btn {
112
+ background: transparent;
113
+ border: none;
114
+ color: var(--text-muted);
115
+ cursor: pointer;
116
+ padding: 5px 7px;
117
+ border-radius: 6px;
118
+ font-size: 12px;
119
+ transition: background 0.15s, color 0.15s;
120
+ }
121
+
122
+ .icon-btn:hover {
123
+ background: var(--bg-hover);
124
+ color: var(--text-primary);
125
+ }
126
+
127
+ /* ---- Upload tile ---- */
128
+
129
+ .upload-tile {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 10px;
133
+ border: 1px solid var(--border);
134
+ border-radius: var(--radius);
135
+ padding: 10px 12px;
136
+ cursor: pointer;
137
+ margin-bottom: 12px;
138
+ background: var(--bg-card);
139
+ transition: border-color 0.15s, background 0.15s;
140
+ }
141
+
142
+ .upload-tile:hover {
143
+ border-color: var(--accent);
144
+ background: var(--accent-dim);
145
+ }
146
+
147
+ .upload-icon {
148
+ font-size: 15px;
149
+ color: var(--accent);
150
+ font-weight: 600;
151
+ }
152
+
153
+ .upload-text {
154
+ font-weight: 500;
155
+ color: var(--text-primary);
156
+ font-size: 13px;
157
+ }
158
+
159
+ /* ---- File meta ---- */
160
+
161
+ .file-meta {
162
+ background: var(--bg-card);
163
+ border: 1px solid var(--border);
164
+ border-radius: var(--radius);
165
+ padding: 8px 10px;
166
+ margin-bottom: 14px;
167
+ }
168
+
169
+ .file-meta-name {
170
+ font-weight: 600;
171
+ font-size: 12px;
172
+ white-space: nowrap;
173
+ overflow: hidden;
174
+ text-overflow: ellipsis;
175
+ }
176
+
177
+ .file-meta-rows {
178
+ color: var(--text-secondary);
179
+ font-size: 11.5px;
180
+ margin-top: 2px;
181
+ }
182
+
183
+ /* ---- Sidebar sections ---- */
184
+
185
+ .sidebar-section {
186
+ margin-bottom: 14px;
187
+ }
188
+
189
+ .sidebar-label {
190
+ font-size: 11px;
191
+ font-weight: 600;
192
+ color: var(--text-muted);
193
+ text-transform: uppercase;
194
+ letter-spacing: 0.04em;
195
+ margin-bottom: 6px;
196
+ display: flex;
197
+ justify-content: space-between;
198
+ align-items: center;
199
+ }
200
+
201
+ .text-input {
202
+ width: 100%;
203
+ padding: 7px 10px;
204
+ border: 1px solid var(--border);
205
+ border-radius: 6px;
206
+ background: var(--bg-input);
207
+ color: var(--text-primary);
208
+ outline: none;
209
+ transition: border-color 0.15s;
210
+ }
211
+
212
+ .text-input:focus {
213
+ border-color: var(--accent);
214
+ }
215
+
216
+ .text-input:disabled,
217
+ .multi-select:disabled,
218
+ .range-input:disabled {
219
+ opacity: 0.4;
220
+ cursor: not-allowed;
221
+ }
222
+
223
+ .date-row {
224
+ display: flex;
225
+ gap: 6px;
226
+ }
227
+
228
+ .date-row .text-input {
229
+ min-width: 0;
230
+ }
231
+
232
+ .multi-select {
233
+ width: 100%;
234
+ border: 1px solid var(--border);
235
+ border-radius: 6px;
236
+ background: var(--bg-input);
237
+ color: var(--text-primary);
238
+ padding: 4px;
239
+ max-height: 100px;
240
+ }
241
+
242
+ .multi-select option {
243
+ padding: 4px 6px;
244
+ border-radius: 4px;
245
+ font-size: 12px;
246
+ }
247
+
248
+ .range-input {
249
+ width: 100%;
250
+ accent-color: var(--accent);
251
+ }
252
+
253
+ .reset-btn {
254
+ margin-top: auto;
255
+ width: 100%;
256
+ padding: 8px;
257
+ border: 1px solid var(--border);
258
+ border-radius: 6px;
259
+ background: var(--bg-card);
260
+ color: var(--text-secondary);
261
+ cursor: pointer;
262
+ font-weight: 500;
263
+ font-size: 12.5px;
264
+ transition: background 0.15s, color 0.15s;
265
+ }
266
+
267
+ .reset-btn:hover:not(:disabled) {
268
+ background: var(--bg-hover);
269
+ color: var(--text-primary);
270
+ }
271
+
272
+ .reset-btn:disabled {
273
+ opacity: 0.4;
274
+ cursor: not-allowed;
275
+ }
276
+
277
+ /* ================ Main Panel ================ */
278
+
279
+ .main {
280
+ flex: 1;
281
+ display: flex;
282
+ flex-direction: column;
283
+ min-width: 0;
284
+ }
285
+
286
+ .topbar {
287
+ display: flex;
288
+ align-items: center;
289
+ gap: 10px;
290
+ padding: 10px 20px;
291
+ border-bottom: 1px solid var(--border);
292
+ }
293
+
294
+ .topbar-title {
295
+ font-weight: 600;
296
+ font-size: 14px;
297
+ flex: 1;
298
+ letter-spacing: -0.01em;
299
+ }
300
+
301
+ .secondary-btn {
302
+ padding: 7px 14px;
303
+ border-radius: 6px;
304
+ border: 1px solid var(--border);
305
+ background: var(--bg-card);
306
+ color: var(--text-primary);
307
+ cursor: pointer;
308
+ font-weight: 500;
309
+ font-size: 12.5px;
310
+ transition: background 0.15s;
311
+ }
312
+
313
+ .secondary-btn:hover:not(:disabled) {
314
+ background: var(--bg-hover);
315
+ }
316
+
317
+ .secondary-btn:disabled {
318
+ opacity: 0.4;
319
+ cursor: not-allowed;
320
+ }
321
+
322
+ .content {
323
+ flex: 1;
324
+ overflow-y: auto;
325
+ padding: 20px 28px 48px 28px;
326
+ }
327
+
328
+ /* ================ Empty State ================ */
329
+
330
+ .empty-state {
331
+ height: 100%;
332
+ display: flex;
333
+ align-items: center;
334
+ justify-content: center;
335
+ }
336
+
337
+ .empty-card {
338
+ text-align: center;
339
+ max-width: 420px;
340
+ }
341
+
342
+ .empty-icon {
343
+ font-size: 36px;
344
+ margin-bottom: 8px;
345
+ opacity: 0.8;
346
+ }
347
+
348
+ .empty-card h1 {
349
+ font-size: 20px;
350
+ font-weight: 600;
351
+ margin: 0 0 6px 0;
352
+ letter-spacing: -0.02em;
353
+ }
354
+
355
+ .empty-card p {
356
+ color: var(--text-secondary);
357
+ line-height: 1.55;
358
+ margin: 0 0 18px 0;
359
+ font-size: 13px;
360
+ }
361
+
362
+ .empty-upload-btn {
363
+ display: inline-block;
364
+ padding: 9px 20px;
365
+ background: var(--accent);
366
+ color: #111;
367
+ border-radius: 6px;
368
+ font-weight: 600;
369
+ font-size: 13px;
370
+ cursor: pointer;
371
+ transition: background 0.15s;
372
+ }
373
+
374
+ .empty-upload-btn:hover {
375
+ background: var(--accent-hover);
376
+ }
377
+
378
+ .empty-hint {
379
+ font-size: 11.5px;
380
+ color: var(--text-muted) !important;
381
+ margin-top: 12px !important;
382
+ }
383
+
384
+ /* ================ Metrics ================ */
385
+
386
+ .metrics-row {
387
+ display: grid;
388
+ grid-template-columns: repeat(4, 1fr);
389
+ gap: 12px;
390
+ margin-bottom: 20px;
391
+ }
392
+
393
+ .metric-card {
394
+ border: 1px solid var(--border);
395
+ border-radius: var(--radius);
396
+ padding: 14px 16px;
397
+ background: var(--bg-card);
398
+ }
399
+
400
+ .metric-value {
401
+ font-size: 20px;
402
+ font-weight: 700;
403
+ letter-spacing: -0.02em;
404
+ }
405
+
406
+ .metric-label {
407
+ color: var(--text-muted);
408
+ font-size: 11.5px;
409
+ margin-top: 3px;
410
+ text-transform: uppercase;
411
+ letter-spacing: 0.03em;
412
+ }
413
+
414
+ /* ================ Tabs ================ */
415
+
416
+ .tabs {
417
+ display: flex;
418
+ gap: 2px;
419
+ border-bottom: 1px solid var(--border);
420
+ margin-bottom: 14px;
421
+ }
422
+
423
+ .tab-btn {
424
+ padding: 8px 14px;
425
+ border: none;
426
+ background: transparent;
427
+ cursor: pointer;
428
+ color: var(--text-muted);
429
+ font-weight: 500;
430
+ font-size: 13px;
431
+ border-bottom: 2px solid transparent;
432
+ margin-bottom: -1px;
433
+ transition: color 0.15s;
434
+ }
435
+
436
+ .tab-btn.active {
437
+ color: var(--text-primary);
438
+ border-bottom-color: var(--accent);
439
+ }
440
+
441
+ .tab-btn:hover:not(.active) {
442
+ color: var(--text-secondary);
443
+ }
444
+
445
+ /* ================ Chart ================ */
446
+
447
+ .chart-card {
448
+ border: 1px solid var(--border);
449
+ border-radius: var(--radius);
450
+ padding: 16px 18px;
451
+ background: var(--bg-card);
452
+ margin-bottom: 20px;
453
+ }
454
+
455
+ .chart-title {
456
+ font-weight: 600;
457
+ font-size: 13px;
458
+ margin-bottom: 12px;
459
+ color: var(--text-secondary);
460
+ }
461
+
462
+ .bar-chart {
463
+ display: flex;
464
+ flex-direction: column;
465
+ gap: 6px;
466
+ max-height: 580px;
467
+ overflow-y: auto;
468
+ }
469
+
470
+ .bar-row {
471
+ display: grid;
472
+ grid-template-columns: 160px 1fr 65px;
473
+ align-items: center;
474
+ gap: 10px;
475
+ font-size: 12px;
476
+ }
477
+
478
+ .bar-row .bar-label {
479
+ white-space: nowrap;
480
+ overflow: hidden;
481
+ text-overflow: ellipsis;
482
+ color: var(--text-secondary);
483
+ cursor: default;
484
+ }
485
+
486
+ .bar-track {
487
+ background: var(--bar-track);
488
+ border-radius: 3px;
489
+ height: 16px;
490
+ position: relative;
491
+ overflow: hidden;
492
+ }
493
+
494
+ .bar-fill {
495
+ background: var(--accent);
496
+ height: 100%;
497
+ border-radius: 3px;
498
+ transition: width 0.25s ease;
499
+ }
500
+
501
+ .bar-row .bar-value {
502
+ text-align: right;
503
+ color: var(--text-muted);
504
+ font-variant-numeric: tabular-nums;
505
+ font-size: 11.5px;
506
+ }
507
+
508
+ .chart-empty {
509
+ color: var(--text-muted);
510
+ padding: 20px 0;
511
+ text-align: center;
512
+ font-size: 12.5px;
513
+ }
514
+
515
+ /* ================ Table ================ */
516
+
517
+ .table-section {
518
+ border: 1px solid var(--border);
519
+ border-radius: var(--radius);
520
+ background: var(--bg-card);
521
+ padding: 16px 18px 10px 18px;
522
+ }
523
+
524
+ .table-header {
525
+ display: flex;
526
+ align-items: center;
527
+ justify-content: space-between;
528
+ margin-bottom: 10px;
529
+ }
530
+
531
+ .table-header h2 {
532
+ font-size: 14px;
533
+ font-weight: 600;
534
+ }
535
+
536
+ .table-wrap {
537
+ overflow-x: auto;
538
+ max-height: 440px;
539
+ overflow-y: auto;
540
+ border-top: 1px solid var(--border);
541
+ }
542
+
543
+ table {
544
+ width: 100%;
545
+ border-collapse: collapse;
546
+ font-size: 12px;
547
+ }
548
+
549
+ thead th {
550
+ position: sticky;
551
+ top: 0;
552
+ background: var(--bg-sidebar);
553
+ text-align: left;
554
+ padding: 8px 10px;
555
+ border-bottom: 1px solid var(--border);
556
+ color: var(--text-muted);
557
+ font-weight: 600;
558
+ cursor: pointer;
559
+ white-space: nowrap;
560
+ font-size: 11px;
561
+ text-transform: uppercase;
562
+ letter-spacing: 0.03em;
563
+ }
564
+
565
+ thead th:hover {
566
+ background: var(--bg-hover);
567
+ }
568
+
569
+ thead th .sort-indicator {
570
+ margin-left: 4px;
571
+ color: var(--text-muted);
572
+ }
573
+
574
+ tbody td {
575
+ padding: 7px 10px;
576
+ border-bottom: 1px solid var(--border-subtle);
577
+ white-space: nowrap;
578
+ max-width: 300px;
579
+ overflow: hidden;
580
+ text-overflow: ellipsis;
581
+ color: var(--text-secondary);
582
+ }
583
+
584
+ tbody tr:hover {
585
+ background: var(--bg-hover);
586
+ }
587
+
588
+ .pagination {
589
+ display: flex;
590
+ align-items: center;
591
+ gap: 8px;
592
+ font-size: 12px;
593
+ color: var(--text-muted);
594
+ }
595
+
596
+ .pagination button {
597
+ border: 1px solid var(--border);
598
+ background: var(--bg-card);
599
+ border-radius: 5px;
600
+ padding: 4px 10px;
601
+ cursor: pointer;
602
+ color: var(--text-primary);
603
+ font-size: 12px;
604
+ transition: background 0.15s;
605
+ }
606
+
607
+ .pagination button:hover:not(:disabled) {
608
+ background: var(--bg-hover);
609
+ }
610
+
611
+ .pagination button:disabled {
612
+ opacity: 0.35;
613
+ cursor: not-allowed;
614
+ }
615
+
616
+ /* ================ Toast ================ */
617
+
618
+ .toast {
619
+ position: fixed;
620
+ bottom: 20px;
621
+ left: 50%;
622
+ transform: translateX(-50%);
623
+ background: var(--bg-card);
624
+ color: var(--text-primary);
625
+ padding: 9px 18px;
626
+ border-radius: 6px;
627
+ font-size: 12.5px;
628
+ border: 1px solid var(--border);
629
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
630
+ z-index: 999;
631
+ }
632
+
633
+ .toast.error {
634
+ border-color: var(--danger);
635
+ color: var(--danger);
636
+ }
637
+
638
+ /* ================ Scrollbar ================ */
639
+
640
+ ::-webkit-scrollbar {
641
+ width: 6px;
642
+ height: 6px;
643
+ }
644
+
645
+ ::-webkit-scrollbar-track {
646
+ background: transparent;
647
+ }
648
+
649
+ ::-webkit-scrollbar-thumb {
650
+ background: var(--border);
651
+ border-radius: 3px;
652
+ }
653
+
654
+ ::-webkit-scrollbar-thumb:hover {
655
+ background: var(--text-muted);
656
+ }
657
+
658
+ /* ================ Responsive ================ */
659
+
660
+ @media (max-width: 900px) {
661
+ .metrics-row {
662
+ grid-template-columns: repeat(2, 1fr);
663
+ }
664
+ .sidebar {
665
+ position: fixed;
666
+ z-index: 50;
667
+ height: 100vh;
668
+ }
669
+ }
templates/index.html ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="color-scheme" content="dark light" />
8
+ <title>Activity Time Explorer</title>
9
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
10
+ </head>
11
+
12
+ <body>
13
+
14
+ <div class="app">
15
+
16
+ <!-- Sidebar (ChatGPT-style rail) -->
17
+ <aside class="sidebar" id="sidebar">
18
+ <div class="sidebar-header">
19
+ <span class="brand">Activity Explorer</span>
20
+ <button id="collapseBtn" class="icon-btn" title="Collapse sidebar">⟨⟨</button>
21
+ </div>
22
+
23
+ <label class="upload-tile" id="uploadTile">
24
+ <input type="file" id="fileInput" accept=".csv" hidden />
25
+ <span class="upload-icon">+</span>
26
+ <span class="upload-text">Upload CSV</span>
27
+ </label>
28
+ <div id="fileMeta" class="file-meta hidden">
29
+ <div class="file-meta-name" id="fileMetaName"></div>
30
+ <div class="file-meta-rows" id="fileMetaRows"></div>
31
+ </div>
32
+
33
+ <div class="sidebar-section">
34
+ <div class="sidebar-label">Search</div>
35
+ <input type="text" id="searchInput" class="text-input" placeholder="domain or URL contains…" disabled />
36
+ </div>
37
+
38
+ <div class="sidebar-section">
39
+ <div class="sidebar-label">Domain</div>
40
+ <select id="domainSelect" class="multi-select" multiple disabled></select>
41
+ </div>
42
+
43
+ <div class="sidebar-section">
44
+ <div class="sidebar-label">Date range</div>
45
+ <div class="date-row">
46
+ <input type="date" id="startDate" class="text-input" disabled />
47
+ <input type="date" id="endDate" class="text-input" disabled />
48
+ </div>
49
+ </div>
50
+
51
+ <div class="sidebar-section">
52
+ <div class="sidebar-label">End Reason</div>
53
+ <select id="reasonSelect" class="multi-select" multiple disabled></select>
54
+ </div>
55
+
56
+ <div class="sidebar-section">
57
+ <div class="sidebar-label">
58
+ Min duration per entry
59
+ <span id="minDurationLabel" class="muted">0s</span>
60
+ </div>
61
+ <input type="range" id="minDuration" class="range-input" min="0" max="0" value="0" disabled />
62
+ </div>
63
+
64
+ <button id="resetBtn" class="reset-btn" disabled>Reset filters</button>
65
+ </aside>
66
+
67
+ <!-- Main panel -->
68
+ <main class="main">
69
+ <div class="topbar">
70
+ <button id="expandBtn" class="icon-btn hidden" title="Expand sidebar">⟩⟩</button>
71
+ <div class="topbar-title">Activity Time Explorer</div>
72
+ <button id="downloadBtn" class="secondary-btn" disabled>↓ Download filtered CSV</button>
73
+ </div>
74
+
75
+ <div class="content">
76
+
77
+ <!-- Empty state -->
78
+ <div id="emptyState" class="empty-state">
79
+ <div class="empty-card">
80
+ <div class="empty-icon">—</div>
81
+ <h1>Explore your activity data</h1>
82
+ <p>Upload a browser activity time-tracking CSV to filter, search, and visualize time spent by domain and
83
+ URL.</p>
84
+ <label class="empty-upload-btn" for="fileInput">Upload CSV</label>
85
+ <p class="empty-hint">Expected columns: Domain, URL, Start Time (UTC), Total Duration (s), End Reason…</p>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Dashboard (hidden until data loaded) -->
90
+ <div id="dashboard" class="dashboard hidden">
91
+
92
+ <div class="metrics-row">
93
+ <div class="metric-card">
94
+ <div class="metric-value" id="metricEntries">0</div>
95
+ <div class="metric-label">Total entries</div>
96
+ </div>
97
+ <div class="metric-card">
98
+ <div class="metric-value" id="metricTime">0m 0s</div>
99
+ <div class="metric-label">Total time</div>
100
+ </div>
101
+ <div class="metric-card">
102
+ <div class="metric-value" id="metricDomains">0</div>
103
+ <div class="metric-label">Unique domains</div>
104
+ </div>
105
+ <div class="metric-card">
106
+ <div class="metric-value" id="metricUrls">0</div>
107
+ <div class="metric-label">Unique URLs</div>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="tabs">
112
+ <button class="tab-btn active" data-tab="domain">By Domain</button>
113
+ <button class="tab-btn" data-tab="url">By URL</button>
114
+ </div>
115
+
116
+ <div class="tab-panel" id="tabDomain">
117
+ <div class="chart-card">
118
+ <div class="chart-title">Time spent by domain (top 25)</div>
119
+ <div id="domainChart" class="bar-chart"></div>
120
+ <div id="domainEmpty" class="chart-empty hidden">No data matches the current filters.</div>
121
+ </div>
122
+ </div>
123
+
124
+ <div class="tab-panel hidden" id="tabUrl">
125
+ <div class="chart-card">
126
+ <div class="chart-title">Time spent by URL (top 25)</div>
127
+ <div id="urlChart" class="bar-chart"></div>
128
+ <div id="urlEmpty" class="chart-empty hidden">No data matches the current filters.</div>
129
+ </div>
130
+ </div>
131
+
132
+ <div class="table-section">
133
+ <div class="table-header">
134
+ <h2>Filtered entries</h2>
135
+ <div class="pagination" id="pagination"></div>
136
+ </div>
137
+ <div class="table-wrap">
138
+ <table id="dataTable">
139
+ <thead id="tableHead"></thead>
140
+ <tbody id="tableBody"></tbody>
141
+ </table>
142
+ </div>
143
+ </div>
144
+
145
+ </div>
146
+
147
+ </div>
148
+ </main>
149
+ </div>
150
+
151
+ <div id="toast" class="toast hidden"></div>
152
+
153
+ <script src="{{ url_for('static', filename='app.js') }}"></script>
154
+ </body>
155
+
156
+ </html>