├── .gitignore ├── .vscode └── settings.json ├── PRIVACY.md ├── README.md ├── manifest.json ├── package.json ├── pnpm-lock.yaml ├── src ├── background.js ├── content.js └── icons │ ├── icon-active.png │ ├── icon-active@2x.png │ ├── icon-active@3x.png │ ├── icon-active@4x.png │ ├── icon.png │ ├── icon@2x.png │ ├── icon@3x.png │ └── icon@4x.png ├── store-icon.png └── store-screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | } 7 | } -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | ClickTick ("we", "our", or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our Chrome extension. 4 | 5 | ## Information We Collect 6 | 7 | ClickTick does not collect, store, or transmit any personal information. The extension operates entirely locally within your browser and does not: 8 | 9 | - Collect any personal data 10 | - Store any information about your browsing history 11 | - Transmit any data to external servers 12 | - Use any tracking or analytics 13 | 14 | ## Data Processing 15 | 16 | All timing measurements and calculations are performed locally in your browser. No data leaves your device. 17 | 18 | ## Changes to This Privacy Policy 19 | 20 | We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. 21 | 22 | ## Contact Us 23 | 24 | If you have any questions about this Privacy Policy, please contact Jenna via [https://x.com/jjenzz]. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ClickTick 2 | 3 | A Chrome extension that measures webpage reaction times to user navigation events. Track how quickly a webpage responds to clicks on buttons and links, with timing displayed in milliseconds and frames. 4 | 5 | 6 | 7 | ## Features 8 | 9 | - Measures time between user interaction and DOM changes 10 | - Displays timing in milliseconds and frames (60fps/120fps) 11 | - Draggable timing display box 12 | - Toggle on/off with a single click 13 | - Works on any SPA (Single Page Application) webpage 14 | 15 | ## Installation 16 | 17 | 1. Download the extension: 18 | 19 | - Click the green "Code" button at the top of this repository 20 | - Select "Download ZIP" 21 | - Extract the ZIP file to a location on your computer 22 | 23 | 2. Load the extension in Chrome: 24 | - Open Chrome and navigate to `chrome://extensions/` 25 | - Enable "Developer mode" in the top-right corner 26 | - Click "Load unpacked" and select the extracted folder 27 | - The ClickTick icon should appear in your Chrome toolbar 28 | 29 | ## Usage 30 | 31 | 1. Click the ClickTick icon in your Chrome toolbar to activate the extension 32 | 2. Click any link or button on the webpage 33 | 3. The timing box will appear showing: 34 | - Time elapsed in milliseconds 35 | - Frames elapsed at 60fps and 120fps 36 | 4. Click the icon again to deactivate the extension 37 | 38 | ## Development 39 | 40 | The extension is built with vanilla JavaScript and follows Chrome's Manifest V3 specification. The source code is organized in the `src` directory: 41 | 42 | - `background.js`: Handles extension activation/deactivation 43 | - `content.js`: Implements the timing measurement and display 44 | 45 | ## License 46 | 47 | MIT License 48 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "ClickTick", 4 | "version": "1.0", 5 | "description": "Measure time between user interaction and page navigation", 6 | "permissions": ["scripting", "activeTab"], 7 | "action": { 8 | "default_title": "ClickTick", 9 | "default_icon": { 10 | "16": "src/icons/icon.png", 11 | "32": "src/icons/icon@2x.png", 12 | "48": "src/icons/icon@3x.png", 13 | "128": "src/icons/icon@4x.png" 14 | } 15 | }, 16 | "icons": { 17 | "16": "src/icons/icon.png", 18 | "32": "src/icons/icon@2x.png", 19 | "48": "src/icons/icon@3x.png", 20 | "128": "src/icons/icon@4x.png" 21 | }, 22 | "background": { 23 | "service_worker": "src/background.js" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "click-tick", 3 | "version": "1.0.0", 4 | "author": "Jenna Smith <175330+jjenzz@users.noreply.github.com> (https://github.com/jjenzz)", 5 | "description": "Measure time between user interaction and page navigation", 6 | "main": "background.js", 7 | "license": "MIT", 8 | "bugs": "https://github.com/jjenzz/click-tick/issues", 9 | "homepage": "https://github.com/jjenzz/click-tick", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/jjenzz/click-tick.git" 13 | }, 14 | "scripts": { 15 | "test": "echo \"Error: no test specified\" && exit 1" 16 | }, 17 | "devDependencies": { 18 | "prettier": "^3.5.3" 19 | }, 20 | "prettier": { 21 | "singleQuote": true, 22 | "printWidth": 120 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | devDependencies: 8 | prettier: 9 | specifier: ^3.5.3 10 | version: 3.5.3 11 | 12 | packages: 13 | 14 | /prettier@3.5.3: 15 | resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} 16 | engines: {node: '>=14'} 17 | hasBin: true 18 | dev: true 19 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | // Track the active state per tab 2 | const activeTabs = new Set(); 3 | 4 | // Helper function to check if content script is active 5 | const checkContentScriptState = (tabId) => { 6 | return new Promise((resolve) => { 7 | const timeout = setTimeout(() => { 8 | resolve(false); 9 | }, 1000); 10 | 11 | chrome.tabs.sendMessage(tabId, { action: 'getState' }, (response) => { 12 | clearTimeout(timeout); 13 | if (chrome.runtime.lastError) { 14 | resolve(false); 15 | } else { 16 | resolve(response?.isActive || false); 17 | } 18 | }); 19 | }); 20 | }; 21 | 22 | chrome.action.onClicked.addListener(async (tab) => { 23 | if (!tab.id || !tab.url?.startsWith('http')) return; 24 | 25 | const tabId = tab.id; 26 | const isContentScriptActive = await checkContentScriptState(tabId); 27 | 28 | if (isContentScriptActive) { 29 | chrome.tabs.sendMessage(tabId, { action: 'deactivate' }); 30 | activeTabs.delete(tabId); 31 | chrome.action.setIcon({ path: 'icons/icon@3x.png', tabId }); 32 | } else { 33 | try { 34 | await chrome.scripting.executeScript({ 35 | target: { tabId }, 36 | files: ['src/content.js'], 37 | }); 38 | } catch (error) { 39 | // If already injected, it will fail. That's okay! 40 | } 41 | chrome.tabs.sendMessage(tabId, { action: 'activate' }); 42 | activeTabs.add(tabId); 43 | chrome.action.setIcon({ path: 'icons/icon-active@3x.png', tabId }); 44 | } 45 | }); 46 | 47 | // Clean up activeTabs when a tab is closed 48 | chrome.tabs.onRemoved.addListener((tabId) => { 49 | activeTabs.delete(tabId); 50 | }); 51 | 52 | // Clean up activeTabs when a tab navigates to a new URL 53 | chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { 54 | if (changeInfo.url) { 55 | activeTabs.delete(tabId); 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | (function (window) { 2 | const PRIMARY_COLOR = '#32CD32'; 3 | 4 | function main() { 5 | window.clickTick ??= new ClickTick(); 6 | } 7 | 8 | /* ------------------------------------------------------------------------------------------------- 9 | * ClickTick 10 | * -----------------------------------------------------------------------------------------------*/ 11 | 12 | class ClickTick { 13 | constructor() { 14 | this.currentUrl = window.location.href; 15 | this.isActive = false; 16 | this.timingBox = null; 17 | this.cursorRing = null; 18 | this.activeObservers = new Map(); 19 | this.activeTimeouts = new Map(); 20 | this.isTracking = false; 21 | this.setupMessageListener(); 22 | this.init(); 23 | } 24 | 25 | setupMessageListener() { 26 | chrome.runtime.onMessage.addListener((message, _, sendResponse) => { 27 | if (message.action === 'getState') { 28 | sendResponse({ isActive: this.isActive }); 29 | return true; 30 | } else if (message.action === 'activate') { 31 | this.start(); 32 | } else if (message.action === 'deactivate') { 33 | this.stop(); 34 | } 35 | }); 36 | } 37 | 38 | init() { 39 | this.timingBox ??= new TimingBox(); 40 | this.cursorRing ??= new CursorRing(); 41 | this.timingBox.show(); 42 | } 43 | 44 | calculateFrames(ms) { 45 | return { 46 | fps60: (ms / 16.6667).toFixed(2), 47 | fps120: (ms / 8.3333).toFixed(2), 48 | }; 49 | } 50 | 51 | cleanupAllEvents() { 52 | for (const [eventType, observer] of this.activeObservers.entries()) { 53 | observer.disconnect(); 54 | this.activeObservers.delete(eventType); 55 | } 56 | 57 | for (const [eventType, timeout] of this.activeTimeouts.entries()) { 58 | clearTimeout(timeout); 59 | this.activeTimeouts.delete(eventType); 60 | } 61 | } 62 | 63 | cleanupEvent(eventType) { 64 | const observer = this.activeObservers.get(eventType); 65 | if (observer) { 66 | observer.disconnect(); 67 | this.activeObservers.delete(eventType); 68 | } 69 | 70 | const timeout = this.activeTimeouts.get(eventType); 71 | if (timeout) { 72 | clearTimeout(timeout); 73 | this.activeTimeouts.delete(eventType); 74 | } 75 | } 76 | 77 | handlePointerDown(event) { 78 | if (event.button !== 0 || this.timingBox.box.contains(event.target)) return; 79 | this.cleanupAllEvents(); 80 | this.trackNavigation(event); 81 | this.cursorRing.show({ x: event.clientX, y: event.clientY }, 'white'); 82 | this.isTracking = true; 83 | } 84 | 85 | handlePointerUp(event) { 86 | const ringCoords = { x: event.clientX, y: event.clientY }; 87 | const prevTimeout = this.activeTimeouts.get('cursor-ring'); 88 | if (prevTimeout) clearTimeout(prevTimeout); 89 | 90 | const timeout = setTimeout(() => this.cursorRing.hide(), 350); 91 | this.activeTimeouts.set('cursor-ring', timeout); 92 | 93 | if (!this.isTracking) return; 94 | this.trackNavigation(event); 95 | this.cursorRing.show({ ...ringCoords, width: 25, height: 25 }, PRIMARY_COLOR); 96 | this.isTracking = false; 97 | } 98 | 99 | trackNavigation(event) { 100 | this.cleanupEvent(event.type); 101 | this.timingBox.loading(event.type); 102 | 103 | const timeout = setTimeout(() => { 104 | this.cleanupEvent(event.type); 105 | this.timingBox.showNoNavMessage(event.type); 106 | }, 2000); 107 | 108 | const startTime = performance.now(); 109 | this.currentUrl = window.location.href; 110 | 111 | const observer = new MutationObserver(() => { 112 | if (window.location.href === this.currentUrl) return; 113 | 114 | const endTime = performance.now(); 115 | const elapsedMs = endTime - startTime; 116 | const frames = this.calculateFrames(elapsedMs); 117 | 118 | this.cleanupEvent(event.type); 119 | this.timingBox.updateSection(event.type, elapsedMs, frames); 120 | }); 121 | 122 | this.activeObservers.set(event.type, observer); 123 | this.activeTimeouts.set(event.type, timeout); 124 | 125 | observer.observe(document, { 126 | childList: true, 127 | subtree: true, 128 | attributes: true, 129 | characterData: true, 130 | }); 131 | } 132 | 133 | start() { 134 | if (this.isActive) return; 135 | this.isActive = true; 136 | document.addEventListener('pointerdown', this.handlePointerDown.bind(this)); 137 | document.addEventListener('pointerup', this.handlePointerUp.bind(this)); 138 | this.init(); 139 | } 140 | 141 | stop() { 142 | if (!this.isActive) return; 143 | this.isActive = false; 144 | 145 | document.removeEventListener('pointerdown', this.handlePointerDown.bind(this)); 146 | document.removeEventListener('pointerup', this.handlePointerUp.bind(this)); 147 | 148 | this.cleanupAllEvents(); 149 | this.timingBox?.remove(); 150 | this.cursorRing?.remove(); 151 | this.timingBox = null; 152 | this.cursorRing = null; 153 | this.isTracking = false; 154 | } 155 | } 156 | 157 | /* ------------------------------------------------------------------------------------------------- 158 | * TimingBox 159 | * -----------------------------------------------------------------------------------------------*/ 160 | 161 | class TimingBox { 162 | constructor() { 163 | this.box = this.createBox(); 164 | this.sections = { 165 | pointerdown: this.createSection('pointerdown'), 166 | pointerup: this.createSection('pointerup'), 167 | }; 168 | this.initializeBox(); 169 | } 170 | 171 | createBox() { 172 | const box = document.createElement('div'); 173 | box.style.cssText = ` 174 | position: fixed; 175 | bottom: 20px; 176 | right: 20px; 177 | background: rgba(0, 0, 0, 0.8); 178 | color: ${PRIMARY_COLOR}; 179 | padding: 12px; 180 | border-radius: 8px; 181 | font-family: Arial, sans-serif; 182 | font-size: 12px; 183 | z-index: 9999; 184 | min-width: 200px; 185 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 186 | cursor: move; 187 | user-select: none; 188 | display: none; 189 | `; 190 | box.setAttribute('data-clicktick', 'true'); 191 | 192 | // Add animation styles 193 | const style = document.createElement('style'); 194 | style.textContent = ` 195 | @keyframes clicktick-pulse { 196 | 0% { opacity: 1; } 197 | 50% { opacity: 0.5; } 198 | 100% { opacity: 1; } 199 | } 200 | .clicktick-pulse { 201 | animation: clicktick-pulse 1s infinite; 202 | } 203 | `; 204 | document.head.appendChild(style); 205 | 206 | return box; 207 | } 208 | 209 | createSection(eventType) { 210 | const section = document.createElement('div'); 211 | section.id = `clicktick-${eventType}`; 212 | 213 | const heading = document.createElement('div'); 214 | heading.setAttribute('data-clicktick-event-type', eventType); 215 | heading.style.color = 'white'; 216 | heading.style.fontWeight = 'bold'; 217 | heading.style.marginBottom = '4px'; 218 | heading.textContent = eventType; 219 | 220 | const ms = document.createElement('div'); 221 | const fps60 = document.createElement('div'); 222 | const fps120 = document.createElement('div'); 223 | ms.textContent = 'No nav detected'; 224 | fps60.style.marginTop = '2px'; 225 | fps120.style.marginTop = '2px'; 226 | 227 | section.appendChild(heading); 228 | section.appendChild(ms); 229 | section.appendChild(fps60); 230 | section.appendChild(fps120); 231 | 232 | return { 233 | element: section, 234 | heading, 235 | ms, 236 | fps60, 237 | fps120, 238 | }; 239 | } 240 | 241 | initializeBox() { 242 | const hr = document.createElement('hr'); 243 | hr.style.cssText = ` 244 | border: none; 245 | border-bottom: 1px solid rgba(255, 255, 255, 0.2); 246 | margin: 8px 0; 247 | `; 248 | this.box.appendChild(this.sections.pointerdown.element); 249 | this.box.appendChild(hr); 250 | this.box.appendChild(this.sections.pointerup.element); 251 | document.body.appendChild(this.box); 252 | this.setupDraggable(); 253 | } 254 | 255 | setupDraggable() { 256 | let isDragging = false; 257 | let currentX; 258 | let currentY; 259 | let initialX; 260 | let initialY; 261 | let xOffset = 0; 262 | let yOffset = 0; 263 | 264 | this.box.addEventListener('pointerdown', (e) => { 265 | initialX = e.clientX - xOffset; 266 | initialY = e.clientY - yOffset; 267 | 268 | if (this.box.contains(e.target)) { 269 | isDragging = true; 270 | document.addEventListener('mousemove', drag); 271 | document.addEventListener('pointerup', dragEnd); 272 | } 273 | }); 274 | 275 | const drag = (e) => { 276 | if (isDragging) { 277 | e.preventDefault(); 278 | currentX = e.clientX - initialX; 279 | currentY = e.clientY - initialY; 280 | 281 | xOffset = currentX; 282 | yOffset = currentY; 283 | 284 | this.box.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`; 285 | } 286 | }; 287 | 288 | const dragEnd = () => { 289 | initialX = currentX; 290 | initialY = currentY; 291 | isDragging = false; 292 | document.removeEventListener('mousemove', drag); 293 | document.removeEventListener('pointerup', dragEnd); 294 | }; 295 | } 296 | 297 | updateSection(eventType, elapsedMs, frames) { 298 | const section = this.sections[eventType]; 299 | if (!section) return; 300 | 301 | section.ms.textContent = `${elapsedMs.toFixed(2)}ms`; 302 | section.fps60.textContent = `${frames.fps60}f @60fps`; 303 | section.fps120.textContent = `${frames.fps120}f @120fps`; 304 | this.ready(eventType); 305 | } 306 | 307 | showNoNavMessage(eventType) { 308 | const section = this.sections[eventType]; 309 | if (!section) return; 310 | 311 | section.heading.classList.remove('clicktick-pulse'); 312 | section.ms.textContent = 'No nav detected'; 313 | section.fps60.textContent = ''; 314 | section.fps120.textContent = ''; 315 | this.ready(eventType); 316 | } 317 | 318 | remove() { 319 | if (this.box && this.box.parentNode) { 320 | this.box.parentNode.removeChild(this.box); 321 | } 322 | } 323 | 324 | show() { 325 | if (this.box) { 326 | this.box.style.display = 'block'; 327 | } 328 | } 329 | 330 | loading(eventType) { 331 | const section = this.sections[eventType]; 332 | section?.heading.classList.add('clicktick-pulse'); 333 | } 334 | 335 | ready(eventType) { 336 | const section = this.sections[eventType]; 337 | section?.heading.classList.remove('clicktick-pulse'); 338 | this.show(); 339 | } 340 | } 341 | 342 | /* ------------------------------------------------------------------------------------------------- 343 | * CursorRing 344 | * -----------------------------------------------------------------------------------------------*/ 345 | 346 | class CursorRing { 347 | constructor() { 348 | this.ring = this.createRing(); 349 | this.initializeRing(); 350 | } 351 | 352 | createRing() { 353 | const ring = document.createElement('div'); 354 | ring.style.cssText = ` 355 | position: fixed; 356 | pointer-events: none; 357 | width: 25px; 358 | height: 25px; 359 | border: 2px solid white; 360 | border-radius: 50%; 361 | transform: translate(-50%, -50%); 362 | opacity: 0; 363 | transition: opacity 0.2s ease-in-out; 364 | transform-origin: center; 365 | transition-property: width, height, opacity; 366 | z-index: 9999; 367 | `; 368 | return ring; 369 | } 370 | 371 | initializeRing() { 372 | document.body.appendChild(this.ring); 373 | } 374 | 375 | show(rect, color) { 376 | this.ring.style.left = `${rect.x}px`; 377 | this.ring.style.top = `${rect.y}px`; 378 | this.ring.style.width = `${rect.width ?? 20}px`; 379 | this.ring.style.height = `${rect.height ?? 20}px`; 380 | this.ring.style.borderColor = color; 381 | this.ring.style.opacity = '1'; 382 | } 383 | 384 | hide() { 385 | this.ring.style.opacity = '0'; 386 | } 387 | 388 | remove() { 389 | if (this.ring && this.ring.parentNode) { 390 | this.ring.parentNode.removeChild(this.ring); 391 | } 392 | } 393 | } 394 | 395 | main(); 396 | })(window); 397 | -------------------------------------------------------------------------------- /src/icons/icon-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon-active.png -------------------------------------------------------------------------------- /src/icons/icon-active@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon-active@2x.png -------------------------------------------------------------------------------- /src/icons/icon-active@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon-active@3x.png -------------------------------------------------------------------------------- /src/icons/icon-active@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon-active@4x.png -------------------------------------------------------------------------------- /src/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon.png -------------------------------------------------------------------------------- /src/icons/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon@2x.png -------------------------------------------------------------------------------- /src/icons/icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon@3x.png -------------------------------------------------------------------------------- /src/icons/icon@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/src/icons/icon@4x.png -------------------------------------------------------------------------------- /store-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/store-icon.png -------------------------------------------------------------------------------- /store-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjenzz/click-tick/dce738887bf65095349c2400466ff13e4e78a27d/store-screenshot.png --------------------------------------------------------------------------------