From dcb634682506151bf68bb0377ba7095f8dffe6f3 Mon Sep 17 00:00:00 2001 From: whirlxd Date: Sat, 21 Jun 2025 20:09:21 +0530 Subject: [PATCH] add tracechain --- submissions/tracechain/README.md | 16 +++ submissions/tracechain/background.js | 208 +++++++++++++++++++++++++++ submissions/tracechain/graph-core.js | 135 +++++++++++++++++ submissions/tracechain/graph.css | 17 +++ submissions/tracechain/icon.png | Bin 0 -> 942 bytes submissions/tracechain/manifest.json | 31 ++++ submissions/tracechain/polyfill.js | 3 + submissions/tracechain/popup.css | 57 ++++++++ submissions/tracechain/popup.html | 35 +++++ submissions/tracechain/popup.js | 183 +++++++++++++++++++++++ submissions/tracechain/print.html | 120 ++++++++++++++++ submissions/tracechain/print.js | 72 ++++++++++ submissions/tracechain/review.css | 114 +++++++++++++++ submissions/tracechain/review.html | 73 ++++++++++ submissions/tracechain/review.js | 90 ++++++++++++ 15 files changed, 1154 insertions(+) create mode 100644 submissions/tracechain/README.md create mode 100644 submissions/tracechain/background.js create mode 100644 submissions/tracechain/graph-core.js create mode 100644 submissions/tracechain/graph.css create mode 100644 submissions/tracechain/icon.png create mode 100644 submissions/tracechain/manifest.json create mode 100644 submissions/tracechain/polyfill.js create mode 100644 submissions/tracechain/popup.css create mode 100644 submissions/tracechain/popup.html create mode 100644 submissions/tracechain/popup.js create mode 100644 submissions/tracechain/print.html create mode 100644 submissions/tracechain/print.js create mode 100644 submissions/tracechain/review.css create mode 100644 submissions/tracechain/review.html create mode 100644 submissions/tracechain/review.js diff --git a/submissions/tracechain/README.md b/submissions/tracechain/README.md new file mode 100644 index 00000000..5511df80 --- /dev/null +++ b/submissions/tracechain/README.md @@ -0,0 +1,16 @@ +# Trace Chain +> A nifty lil browser extension that lets you trace the paths you take across your browsing sessions everyday + + + +## Features +- **Path Tracking**: Automatically tracks the paths you take across your browsing sessions. +- **Path Visualization**: Visualize your browsing paths with a graph view>?? +- **PDF Recaps**: Generate PDF recaps of your browsing paths for easy sharing and review. +- **AI Insights**: Get AI-generated insights on your browsing habits. +- **Customizable**: Review Board - For your research modes when you open too many tabs in a short period of time. +- **Privacy Focused**: Your data stays on your device, no external servers involved. +- **Open Source**: Fully open source, contributions are welcome! + +#### AI Use Disclaimer +> I used ai for some debugging and fixing a lot of bug fixes and interacting with browser api. I used it to generate some of the code of graph-core since my iteration were unoptimized and slow comparatively. \ No newline at end of file diff --git a/submissions/tracechain/background.js b/submissions/tracechain/background.js new file mode 100644 index 00000000..fc756792 --- /dev/null +++ b/submissions/tracechain/background.js @@ -0,0 +1,208 @@ +let isIdle = false; + +const todayKey = () => new Date().toISOString().slice(0, 10); +const lastURL = new Map(); +chrome.idle.setDetectionInterval(120); +chrome.idle.onStateChanged.addListener((state) => { + isIdle = state !== "active"; +}); + +function normalise(obj = {}) { + return { visits: obj.visits ?? [], edges: obj.edges ?? [] }; +} +function push(key, field, obj) { + if (isIdle) return; // skip idle periods + chrome.storage.local.get([key], (d) => { + const day = normalise(d[key]); + day[field].push(obj); + chrome.storage.local.set({ [key]: day }); + }); +} +// ensure {visits:[],edges:[]} skeleton + +function save(key, day) { + chrome.storage.local.set({ [key]: day }); +} + +function safePush(key, field, obj) { + chrome.storage.local.get([key], (data) => { + const day = normalise(data[key]); + day[field].push(obj); + save(key, day); + }); +} + +chrome.webNavigation.onCommitted.addListener((d) => { + if (d.frameId) return; // ignore iframes + + const visit = { + url: d.url, + t: Date.now(), + tabId: d.tabId, + type: d.transitionType, + }; + safePush(todayKey(), "visits", visit); + + const prev = lastURL.get(d.tabId); + if (prev) + safePush(todayKey(), "edges", { from: prev, to: d.url, t: Date.now() }); + lastURL.set(d.tabId, d.url); +}); +chrome.webNavigation.onCreatedNavigationTarget.addListener((d) => { + const src = lastURL.get(d.sourceTabId); + if (src) + safePush(todayKey(), "edges", { from: src, to: d.url, t: Date.now() }); +}); + +chrome.runtime.onMessage.addListener((msg, _s, res) => { + if (msg?.type === "GET_DAY") { + chrome.storage.local.get([todayKey()], (d) => + res(normalise(d[todayKey()])), + ); + return true; + } +}); + +const BURST_WINDOW_MIN = 10; // minute observation window +const BURST_TABS = 9; // tab count that triggers a burst +const REMINDER_DELAY_MIN = 15; // nudge 15 minutes after burst + +const BURST_WINDOW_MS = BURST_WINDOW_MIN * 60 * 1_000; +const NUDGE_ID = "recap-nudge"; +const NUDGE_ICON = chrome.runtime.getURL("icon.png"); + +let openTimes = []; +let burstTabs = []; +let alarmActive = false; + +chrome.tabs.onCreated.addListener((tab) => { + const now = Date.now(); + openTimes.push(now); + burstTabs.push({ + tabId: tab.id, + url: tab.pendingUrl || tab.url || "about:blank", + title: "(new tab)", + }); + + // prune entries outside the window + while (openTimes.length && now - openTimes[0] > BURST_WINDOW_MS) { + openTimes.shift(); + burstTabs.shift(); + } + + console.log("[BurstDetector] window =", openTimes.length, "tabs"); + + if (!alarmActive && openTimes.length >= BURST_TABS) { + const cache = burstTabs.map(({ url, title }) => ({ url, title })); + chrome.storage.local.set({ burstCache: cache }, () => { + if (chrome.runtime.lastError) { + console.error( + "[BG] Failed to set burstCache:", + chrome.runtime.lastError, + ); + } else { + console.log("[BG] burstCache set:", cache); + } + }); + chrome.alarms.create(NUDGE_ID, { delayInMinutes: REMINDER_DELAY_MIN }); + alarmActive = true; + console.log("[BurstDetector] Burst detected → alarm scheduled"); + } +}); + +chrome.webNavigation.onCommitted.addListener((d) => { + if (d.frameId) return; // ignore iframes + + const visit = { + url: d.url, + t: Date.now(), + tabId: d.tabId, + type: d.transitionType, + }; + safePush(todayKey(), "visits", visit); + + const prev = lastURL.get(d.tabId); + if (prev) + safePush(todayKey(), "edges", { from: prev, to: d.url, t: Date.now() }); + lastURL.set(d.tabId, d.url); + + const idx = burstTabs.findIndex((t) => t.tabId === d.tabId); + if (idx !== -1) { + chrome.tabs.get(d.tabId, (tab) => { + burstTabs[idx].url = d.url; + burstTabs[idx].title = tab?.title ? tab.title : d.url; + const cache = burstTabs.map(({ url, title }) => ({ url, title })); + chrome.storage.local.set({ burstCache: cache }, () => { + if (chrome.runtime.lastError) { + console.error( + "[BG] Failed to update burstCache:", + chrome.runtime.lastError, + ); + } else { + console.log("[BG] burstCache updated:", cache); + } + }); + }); + } +}); + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg && msg.type === "REVIEW_LOADED") { + openTimes = []; + burstTabs = []; + chrome.storage.local.remove("burstCache", () => { + if (chrome.runtime.lastError) { + console.error( + "[BG] Failed to remove burstCache:", + chrome.runtime.lastError, + ); + } else { + console.log("[BG] burstCache removed after review loaded"); + } + }); + } +}); + +chrome.notifications.onClicked.addListener((id) => { + if (id !== NUDGE_ID) return; + openReview(); + chrome.notifications.clear(id); +}); + +chrome.notifications.onClicked.addListener((id) => { + if (id !== NUDGE_ID) return; + openReview(); + chrome.notifications.clear(id); +}); + +function openReview() { + chrome.tabs.create({ url: chrome.runtime.getURL("review.html") }); +} + +// keep the worker alive forcefuilly +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== NUDGE_ID) return; + + chrome.notifications + .create(NUDGE_ID, { + type: "basic", + iconUrl: NUDGE_ICON, + title: "Tab spree detected!", + message: "Click to triage your new tabs.", + requireInteraction: true, // keeps notification onscreen (Chrome >= 67) + }) + .then(() => { + setTimeout( + () => + chrome.notifications.getAll((nots) => { + if (nots[NUDGE_ID]) { + openReview(); + chrome.notifications.clear(NUDGE_ID); + } + }), + 30_000, + ); + }); + + alarmActive = false; +}); diff --git a/submissions/tracechain/graph-core.js b/submissions/tracechain/graph-core.js new file mode 100644 index 00000000..2c33b2fc --- /dev/null +++ b/submissions/tracechain/graph-core.js @@ -0,0 +1,135 @@ +function drawGraph(svg, visits = [], edges = []) { + svg.innerHTML = ""; + if (!edges.length) return; + + const VB = svg.viewBox.baseVal; + const W = VB?.width ? VB.width : svg.clientWidth || 800; + const H = VB?.height ? VB.height : svg.clientHeight || 450; + + const host = (u) => new URL(u).hostname.replace(/^www\./, ""); + + const counts = {}; + // biome-ignore lint/suspicious/noAssignInExpressions: + // biome-ignore lint/complexity/noForEach: + visits.forEach((v) => (counts[host(v.url)] = (counts[host(v.url)] || 0) + 1)); + + // Ensure every edge endpoint is represented even if count==0 + // biome-ignore lint/complexity/noForEach: + edges.forEach((e) => { + // biome-ignore lint/suspicious/noAssignInExpressions: + // biome-ignore lint/complexity/noForEach: + [host(e.from), host(e.to)].forEach((h) => (counts[h] ??= 0)); + }); + + const nodes = Object.fromEntries( + Object.entries(counts).map(([h, n]) => [ + h, + { id: h, count: n, x: 0, y: 0, vx: 0, vy: 0 }, + ]), + ); + const nodeArr = Object.values(nodes); + + const rand01 = (str) => { + let h = 2166136261 >>> 0; + for (let i = 0; i < str.length; i++) + h = Math.imul(h ^ str.charCodeAt(i), 16777619); + return (h >>> 0) / 2 ** 32; // 0‥1 + }; + const R0 = 0.35 * Math.min(W, H); + // biome-ignore lint/complexity/noForEach: + nodeArr.forEach((n) => { + const a = 2 * Math.PI * rand01(n.id); + n.x = W / 2 + Math.cos(a) * R0; + n.y = H / 2 + Math.sin(a) * R0; + }); + + /* ---------- toy force sim ---------- */ + const ideal = 140; + const steps = 350; + const repelC = ideal ** 2 / 4; + for (let s = 0; s < steps; s++) { + // repulsion + for (let i = 0; i < nodeArr.length; i++) + for (let j = i + 1; j < nodeArr.length; j++) { + const a = nodeArr[i]; + const b = nodeArr[j]; + let dx = a.x - b.x; + let dy = a.y - b.y; + const dist = Math.hypot(dx, dy) || 1e-6; + const rep = repelC / dist; + dx /= dist; + dy /= dist; + a.vx += dx * rep; + a.vy += dy * rep; + b.vx -= dx * rep; + b.vy -= dy * rep; + } + // attraction + // biome-ignore lint/complexity/noForEach: + edges.forEach(({ from, to }) => { + const a = nodes[host(from)]; + const b = nodes[host(to)]; + if (!a || !b) return; + let dx = a.x - b.x; + let dy = a.y - b.y; + const dist = Math.hypot(dx, dy) || 1e-6; + const k = (dist - ideal) * 0.08; + dx /= dist; + dy /= dist; + a.vx -= dx * k; + a.vy -= dy * k; + b.vx += dx * k; + b.vy += dy * k; + }); + // integrate + damp + // biome-ignore lint/complexity/noForEach: + nodeArr.forEach((n) => { + n.vx *= 0.6; + n.vy *= 0.6; + n.x = Math.min(W - 30, Math.max(30, n.x + n.vx * 0.02)); + n.y = Math.min(H - 30, Math.max(30, n.y + n.vy * 0.02)); + }); + } + + /* ---------- draw ---------- */ + const $ = (tag, attr) => { + const e = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const k in attr) e.setAttribute(k, attr[k]); + return e; + }; + + // biome-ignore lint/complexity/noForEach: + edges.forEach(({ from, to }) => { + const a = nodes[host(from)]; + const b = nodes[host(to)]; + if (a && b) + svg.appendChild( + $("line", { + x1: a.x, + y1: a.y, + x2: b.x, + y2: b.y, + stroke: "#bbb", + "stroke-width": 1, + }), + ); + }); + + // biome-ignore lint/complexity/noForEach: + nodeArr.forEach((n) => { + const r = 14 + Math.log2(n.count + 1) * 6; + svg.appendChild($("circle", { cx: n.x, cy: n.y, r, fill: "#4da3ff" })); + + const txt = $("text", { + x: n.x, + y: n.y + 4, + fill: "#fff", + "text-anchor": "middle", + }); + const label = n.id.length > 18 ? `${n.id.slice(0, 16)}…` : n.id; + const fs = Math.min(12, ((r - 3) * 2) / label.length + 4); // heuristic + txt.setAttribute("font-size", fs.toFixed(1)); + txt.textContent = label; + svg.appendChild(txt); + }); +} diff --git a/submissions/tracechain/graph.css b/submissions/tracechain/graph.css new file mode 100644 index 00000000..79eb33c2 --- /dev/null +++ b/submissions/tracechain/graph.css @@ -0,0 +1,17 @@ +body { + margin: 0; + font: 14px / 1.3 sans-serif; + background: #111; + color: #ddd; + text-align: center; +} +h1 { + margin: 8px 0 4px; + font-size: 18px; +} +svg { + border: 1px solid #333; + background: #000; + margin: auto; + display: block; +} diff --git a/submissions/tracechain/icon.png b/submissions/tracechain/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..92d6a913f9cf83f3d66f7d413ddd7558bf23e329 GIT binary patch literal 942 zcmV;f15x~mP)^on8?>)Wa z`A3#4OSWZtkO7D|pwF1-3y~oj5)C|^Pp|54bc;kfscY0 zydZkTyjXUwNJoNK!MqE|4W1dI9ex}vMp~m6A^e0sb9n2J8cL!qoE^3b1J1dJ{&yV? zMBm8Ke?wECTQYTEv(Jb=C-5EmJ~o1X0ZkAgyd`>qmS78Gy&^>lPi8ut4y!1ib1XDOE(=unNe;c?x<0z-B8A5l z+zupP;1E6FC8WjnHQWxKmGVbc;6bP;Y5nctM&PfWrBF50EO0ZzSFjbz3@c!Sid$5m z6v~F00#f5^cw8WQZfv;Iqt;%>Ly(pxKYxe5ZK+bLBVn`>Cq?+!kmBS9<6k_3!pA)FvFZ32 z5c|lt_jGxO<-?%?m&P>ptd4uecZ&DV&=weebm#&c2@T{1#x1EfIKFs7mq2HYCFaHH zsWw|=i&capNShb@4Z4$XW5H{kI93^Of3+FG`6b>|njAc_bC>?R&hhr}4_4ldU!pAO QPyhe`07*qoM6N<$f*0MJ@&Et; literal 0 HcmV?d00001 diff --git a/submissions/tracechain/manifest.json b/submissions/tracechain/manifest.json new file mode 100644 index 00000000..d48e3a30 --- /dev/null +++ b/submissions/tracechain/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "TraceChain", + "description": "Visualise your daily web journey and get AI-powered summaries – all locally.", + "version": "0.1.1", + "permissions": [ + "storage", + "tabs", + "history", + "webNavigation", + "activeTab", + "idle", + "alarms", + "notifications" + ], + "host_permissions": ["*://*/*", "https://ai.hackclub.com/*"], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_title": "TraceChain", + "default_popup": "popup.html" + }, + "commands": { + "_execute_action": { "suggested_key": { "default": "Ctrl+Shift+K" } } + }, + + "web_accessible_resources": [ + { "resources": ["print.html", "graph.html"], "matches": [""] } + ] +} diff --git a/submissions/tracechain/polyfill.js b/submissions/tracechain/polyfill.js new file mode 100644 index 00000000..4f86d3cd --- /dev/null +++ b/submissions/tracechain/polyfill.js @@ -0,0 +1,3 @@ +if (typeof browser === "undefined") { + var browser = chrome; +} diff --git a/submissions/tracechain/popup.css b/submissions/tracechain/popup.css new file mode 100644 index 00000000..a5f533f3 --- /dev/null +++ b/submissions/tracechain/popup.css @@ -0,0 +1,57 @@ +body { + margin: 0; + padding: 12px; + width: 380px; + background: #1e1e1e; + color: #e7e7e7; + font: 14px / 1.4 system-ui, sans-serif; +} +h1 { + margin: 0 0 12px; + font-size: 18px; +} +h2 { + margin: 14px 0 6px; + font-size: 15px; +} +a { + color: #4da3ff; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +.box { + border: 1px solid #333; + border-radius: 4px; + padding: 6px; +} +.tall { + max-height: 160px; + overflow-y: auto; +} +.small { + max-height: 120px; + overflow-y: auto; + white-space: pre-wrap; +} + +.btn-row { + display: flex; + gap: 6px; + margin: 8px 0 12px; +} +.btn-row button { + flex: 1; + padding: 6px 0; + border: none; + border-radius: 4px; + background: #3b82f6; + color: #fff; + font-weight: 600; + cursor: pointer; +} +.btn-row button:hover { + background: #2563eb; +} diff --git a/submissions/tracechain/popup.html b/submissions/tracechain/popup.html new file mode 100644 index 00000000..4aa320dc --- /dev/null +++ b/submissions/tracechain/popup.html @@ -0,0 +1,35 @@ + + + + + + TraceChain + + + + + +

TraceChain

+ +

Journey paths

+
(loading…)
+ +
+ + + +
+ + +

Stats

+
    +

    AI output

    +
    
    +    
    +
    +    
    +
    +
    +
    +
    +
    \ No newline at end of file
    diff --git a/submissions/tracechain/popup.js b/submissions/tracechain/popup.js
    new file mode 100644
    index 00000000..37a2a9fd
    --- /dev/null
    +++ b/submissions/tracechain/popup.js
    @@ -0,0 +1,183 @@
    +// Polyfill for browser API
    +window.browser = window.browser || window.chrome;
    +
    +document.addEventListener("DOMContentLoaded", () => initPopup());
    +
    +async function initPopup() {
    +	const gtrr = (id) => document.getElementById(id);
    +	const elPath = gtrr("paths");
    +	const elStat = gtrr("stats");
    +	const elAI = gtrr("summary");
    +
    +	const btnQuick = gtrr("recapBtn");
    +	const btnFull = gtrr("fullBtn");
    +	const btnPDF = gtrr("pdfBtn");
    +	const btnGraph = gtrr("graphBtn");
    +
    +	const raw = await getToday();
    +	const muted = await getMuted(); // crass or domains with too many hits which one might not need
    +	const hideDom = (url) => muted.includes(host(url));
    +
    +	const visits = raw.visits.filter((v) => !hideDom(v.url));
    +	const edges = raw.edges.filter((e) => !hideDom(e.from) && !hideDom(e.to));
    +
    +	elPath && renderPaths(edges, elPath);
    +	elStat && renderStats(visits, edges, elStat, muted);
    +	if (btnQuick)
    +		btnQuick.onclick = () =>
    +			askAI(
    +				unique(visits.map((v) => host(v.url))).slice(-60),
    +				"Give me a crisp 3-bullet summary of the user's web activity today speculating what they were doing and providing insights and suggestions to better help them. Address it directly to the user and don't tell them what you are giving them dive straight into the recap.",
    +				elAI,
    +			);
    +
    +	if (btnFull)
    +		btnFull.onclick = () =>
    +			askAI(
    +				[
    +					"TIMELINE:",
    +					...visits.map((v) => `${time(v.t)} — ${host(v.url)}`),
    +					"",
    +					"STATS:",
    +					...buildStats(visits, edges),
    +				],
    +				"Turn this into a readable single-paragraph recap",
    +				elAI,
    +			);
    +
    +	if (btnPDF)
    +		btnPDF.onclick = () =>
    +			browser.tabs.create({ url: browser.runtime.getURL("print.html") });
    +
    +	if (btnGraph)
    +		btnGraph.onclick = () =>
    +			browser.tabs.create({ url: browser.runtime.getURL("graph.html") });
    +}
    +
    +const host = (u) => new URL(u).hostname.replace(/^www\./, "");
    +const time = (t) =>
    +	new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
    +const unique = (a) => [...new Set(a)];
    +
    +function getToday() {
    +	return new Promise((res) =>
    +		browser.runtime.sendMessage({ type: "GET_DAY" }, (d) =>
    +			res({ visits: d?.visits ?? [], edges: d?.edges ?? [] }),
    +		),
    +	);
    +}
    +
    +const getMuted = () =>
    +	new Promise((r) =>
    +		browser.storage.sync.get(["muted"], (d) => r(d.muted ?? [])),
    +	);
    +const saveMuted = (arr) => browser.storage.sync.set({ muted: arr });
    +
    +function renderPaths(edges, box) {
    +	box.innerHTML = edges.length
    +		? edges
    +				.map(
    +					(e) =>
    +						``,
    +				)
    +				.join("")
    +		: "(no paths yet)";
    +}
    +
    +function renderStats(vis, edg, ul, muted) {
    +	const stats = buildStats(vis, edg);
    +	ul.innerHTML = stats.map((s) => `
  • ${s}
  • `).join(""); + + if (!vis.length) return; + const domSet = new Set(vis.map((v) => host(v.url))); + + ul.insertAdjacentHTML("beforeend", "
    Include in recap:"); + domSet.forEach((d) => { + const id = `chk-${d}`; + const li = document.createElement("li"); + li.style.opacity = muted.includes(d) ? 0.5 : 1; + li.innerHTML = ``; + ul.appendChild(li); + document.getElementById(id).onchange = (e) => { + const next = new Set(muted); + e.target.checked ? next.delete(d) : next.add(d); + saveMuted([...next]).then(() => location.reload()); + }; + }); +} + +function buildStats(vis, edg) { + if (!vis.length) return ["No browsing data yet."]; + + /* domain count */ + const counts = {}; + // biome-ignore lint/complexity/noForEach: + vis.forEach((v) => { + const h = host(v.url); + counts[h] = (counts[h] || 0) + 1; + }); + const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || ["—", 0]; + + /* times */ + const spanMin = Math.round((vis.at(-1).t - vis[0].t) / 60000); + let dwell = 0; + for (let i = 0; i < vis.length - 1; i++) + if (vis[i].tabId === vis[i + 1].tabId) dwell += vis[i + 1].t - vis[i].t; + dwell = Math.round(dwell / 60000); + + /* misc */ + const link = vis.filter((v) => v.type === "link").length; + const typed = vis.filter((v) => v.type === "typed").length; + const search = vis.filter((v) => /google|duckduckgo|bing/.test(v.url)).length; + + let longest = 1; + let cur = 1; + for (let i = 1; i < vis.length; i++) { + cur = vis[i].tabId === vis[i - 1].tabId ? cur + 1 : 1; + longest = Math.max(longest, cur); + } + + return [ + `Pages visited: ${vis.length}`, + `Unique domains: ${Object.keys(counts).length}`, + `Tabs opened: ${new Set(vis.map((v) => v.tabId)).size}`, + `Top domain: ${top[0]} (${top[1]} hits)`, + `Browsing span: ${spanMin} min`, + `Est. on-page time: ${dwell} min`, + `Link navigations: ${link}`, + `Typed URLs: ${typed}`, + `Search hits: ${search}`, + `Edges logged: ${edg.length}`, + `Longest same-tab chain: ${longest} pages`, + ]; +} + +async function askAI(lines, sysPrompt, outBox) { + if (!outBox) return; + outBox.textContent = "Thinking…"; + try { + const r = await fetch("https://ai.hackclub.com/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "gpt-3.5-turbo", + messages: [ + { role: "system", content: sysPrompt }, + { + role: "user", + content: Array.isArray(lines) ? lines.join("\n") : lines, + }, + ], + }), + }); + const j = await r.json(); + outBox.textContent = + j?.choices?.[0]?.message?.content?.trim() || "No summary returned."; + } catch (e) { + console.error(e); + outBox.textContent = "AI request failed."; + } +} diff --git a/submissions/tracechain/print.html b/submissions/tracechain/print.html new file mode 100644 index 00000000..8674bdc9 --- /dev/null +++ b/submissions/tracechain/print.html @@ -0,0 +1,120 @@ + + + + + + TraceChain — Daily Report + + + + +

    TraceChain —

    + +
    +

    Timeline

    + + + + + + + + +
    TimeDomain
    +
    + +
    +

    Stats

    +
    +
    + +
    +

    Journey Graph

    + +
    + + + + + + \ No newline at end of file diff --git a/submissions/tracechain/print.js b/submissions/tracechain/print.js new file mode 100644 index 00000000..89c3d98b --- /dev/null +++ b/submissions/tracechain/print.js @@ -0,0 +1,72 @@ +window.browser = window.browser || window.chrome; + +browser.runtime.sendMessage({ type: "GET_DAY" }, ({ visits, edges }) => { + if (!visits.length) { + document.body.innerHTML = "

    No data for today.

    "; + return; + } + const fmt = (d) => + new Date(d).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + /* date */ + document.getElementById("date").textContent = new Date( + visits[0].t, + ).toLocaleDateString(); + + /* timeline table */ + const tb = document.querySelector("#timelineTbl tbody"); + // biome-ignore lint/complexity/noForEach: + visits.forEach((v) => { + const tr = document.createElement("tr"); + tr.innerHTML = `${fmt(v.t)}${new URL(v.url).hostname.replace(/^www\./, "")}`; + tb.appendChild(tr); + }); + + const stats = buildStats(visits, edges); + const grid = document.getElementById("statsGrid"); + // biome-ignore lint/complexity/noForEach: + stats.forEach((s) => { + const m = s.match(/^(.*?): (.*)$/); + grid.innerHTML += `
    ${m ? m[2] : s}${m ? m[1] : ""}
    `; + }); + + drawGraph(document.getElementById("g"), visits, edges); + + /* auto-print after a tick */ + setTimeout(() => window.print(), 600); +}); + +function buildStats(vis, edg) { + const host = (u) => new URL(u).hostname.replace(/^www\./, ""); + const counts = {}; + // biome-ignore lint/complexity/noForEach: + // biome-ignore lint/suspicious/noAssignInExpressions: + vis.forEach((v) => (counts[host(v.url)] = (counts[host(v.url)] || 0) + 1)); + const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || ["—", 0]; + const span = Math.round((vis.at(-1).t - vis[0].t) / 60000); + let dwell = 0; + for (let i = 0; i < vis.length - 1; i++) + if (vis[i].tabId === vis[i + 1].tabId) dwell += vis[i + 1].t - vis[i].t; + dwell = Math.round(dwell / 60000); + let longest = 1; + let c = 1; + for (let i = 1; i < vis.length; i++) { + c = vis[i].tabId === vis[i - 1].tabId ? c + 1 : 1; + longest = Math.max(longest, c); + } + const link = vis.filter((v) => v.type === "link").length; + const typed = vis.filter((v) => v.type === "typed").length; + const search = vis.filter((v) => /google|duck|bing/.test(v.url)).length; + return [ + `Pages visited: ${vis.length}`, + `Unique domains: ${Object.keys(counts).length}`, + `Tabs opened: ${new Set(vis.map((v) => v.tabId)).size}`, + `Top domain: ${top[0]} (${top[1]} hits)`, + `Browsing span: ${span} min`, + `Est. on-page time: ${dwell} min`, + `Link navigations: ${link}`, + `Typed URLs: ${typed}`, + `Search hits: ${search}`, + `Edges logged: ${edg.length}`, + `Longest same-tab chain: ${longest} pages`, + ]; +} diff --git a/submissions/tracechain/review.css b/submissions/tracechain/review.css new file mode 100644 index 00000000..f3e1a6d9 --- /dev/null +++ b/submissions/tracechain/review.css @@ -0,0 +1,114 @@ +:root { + --bg: #f7f8fb; + --card: #fff; + --text: #1f1f1f; + --accent: #2f53ff; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #121212; + --card: #1e1e1e; + --text: #e0e0e0; + --accent: #4da3ff; + } +} +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} +body { + background: var(--bg); + color: var(--text); + font: 14px / 1.45 system-ui, sans-serif; + display: flex; + justify-content: center; + padding: 40px; +} +.card { + background: var(--card); + padding: 24px 28px; + border-radius: 10px; + width: 100%; + max-width: 700px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07); +} +h1 { + font-size: 26px; + margin-bottom: 4px; +} +.sub { + margin: 0 0 18px; + font-size: 14px; + opacity: 0.75; + text-align: center; +} +input { + width: 100%; + padding: 8px 10px; + margin-bottom: 14px; + border: 1px solid #bbb; + border-radius: 5px; + font-size: 14px; +} +ul { + list-style: none; + margin: 0; + padding: 0; + max-height: 60vh; + overflow: auto; +} +li { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0; + padding: 8px 10px; + background: var(--bg); + border-radius: 6px; +} +.icon { + width: 16px; + height: 16px; +} +a.title { + flex: 1; + color: var(--accent); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.btns { + display: flex; + gap: 6px; +} +button { + background: #e0e4ff; + border: none; + border-radius: 4px; + cursor: pointer; + padding: 2px 7px; + font-size: 13px; +} +@media (prefers-color-scheme: dark) { + li { + background: #2a2a2a; + } + button { + background: #3a3a3a; + color: #e0e0e0; + } +} +.empty { + padding: 40px 0; + text-align: center; + opacity: 0.6; +} + +button svg { + color: inherit; + width: 18px; + height: 18px; + vertical-align: middle; +} diff --git a/submissions/tracechain/review.html b/submissions/tracechain/review.html new file mode 100644 index 00000000..224d4bdc --- /dev/null +++ b/submissions/tracechain/review.html @@ -0,0 +1,73 @@ + + + + + + TraceChain · Review Board + + + + + +
    +

    Review Board

    +

    You opened a burst of pages a moment ago.
    + Pin, close, or copy any you still need.

    + + + +
      +
      + + + + + + + + + + + + \ No newline at end of file diff --git a/submissions/tracechain/review.js b/submissions/tracechain/review.js new file mode 100644 index 00000000..c625c87f --- /dev/null +++ b/submissions/tracechain/review.js @@ -0,0 +1,90 @@ +// Polyfill for browser API +window.browser = window.browser || window.chrome; + +const list = document.getElementById("list"); +const rowTpl = document.getElementById("row").content; +const search = document.getElementById("search"); + +let pages = []; + +browser.storage.local.get(["burstCache"]).then(({ burstCache }) => { + if (!Array.isArray(burstCache)) { + console.error("[Review] burstCache is not an array", burstCache); + pages = []; + } else { + pages = burstCache; + } + console.log("[Review] Loaded burstCache:", pages); + render(pages); + // Notify background that review board is loaded + browser.runtime.sendMessage({ type: "REVIEW_LOADED" }); +}); + +search.addEventListener("input", () => filter()); + +document.addEventListener("keydown", (e) => { + if (e.ctrlKey && e.key.toLowerCase() === "k") { + e.preventDefault(); + search.focus(); + } +}); + +function filter() { + const q = search.value.toLowerCase(); + render( + pages.filter( + (p) => + p.title.toLowerCase().includes(q) || p.url.toLowerCase().includes(q), + ), + ); +} + +function render(arr) { + list.innerHTML = ""; + if (!arr.length) { + console.warn("[Review] Nothing to review!"); + list.innerHTML = '

      Nothing to review!

      '; + return; + } + + // biome-ignore lint/complexity/noForEach: + arr.forEach((p) => { + const li = rowTpl.cloneNode(true); + li.querySelector(".icon").src = `chrome://favicon/${p.url}`; + const a = li.querySelector("a.title"); + a.href = p.url; + a.textContent = p.title || p.url; + + // biome-ignore lint/complexity/noForEach: + li.querySelectorAll("button").forEach((btn) => { + btn.onclick = () => handle(btn.dataset.cmd, p, li); + }); + list.appendChild(li); + }); +} + +function handle(cmd, p, li) { + if (cmd === "copy") { + navigator.clipboard.writeText(p.url); + return; + } + browser.tabs.query({ url: p.url }).then((tabs) => { + if (cmd === "open") { + if (tabs.length) return browser.tabs.update(tabs[0].id, { active: true }); + return browser.tabs.create({ url: p.url }); + } + if (cmd === "pin") { + if (tabs.length) + // biome-ignore lint/complexity/noForEach: + tabs.forEach((t) => browser.tabs.update(t.id, { pinned: !t.pinned })); + else browser.tabs.create({ url: p.url, pinned: true }); + } + if (cmd === "close") { + if (tabs.length) { + // biome-ignore lint/complexity/noForEach: + tabs.forEach((t) => browser.tabs.remove(t.id)); + li.style.opacity = 0.4; + } + } + }); +}