Greasy Fork is available in English.
Track visits per URL, show corner badge history & link hover info - Massive Capacity (10K URLs) - ES2020+ & Smooth Tooltips. Advanced URL normalization and performance optimizations.
// ==UserScript==
// @name URL Visit Tracker (Improved)
// @namespace https://github.com/hongmd/userscript-improved
// @version 2.7.0
// @description Track visits per URL, show corner badge history & link hover info - Massive Capacity (10K URLs) - ES2020+ & Smooth Tooltips. Advanced URL normalization and performance optimizations.
// @author hongmd
// @contributor Original idea by Chewy
// @license MIT
// @homepageURL https://github.com/hongmd/userscript-improved
// @supportURL https://github.com/hongmd/userscript-improved/issues
// @match https://*/*
// @run-at document-start
// @noframes
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function () {
'use strict';
// Configuration options
const CONFIG = {
MAX_VISITS_STORED: 20,
MAX_URLS_STORED: 10000, // Massive capacity for extensive tracking
CLEANUP_THRESHOLD: 12000, // Cleanup when exceeding this (20% buffer)
HOVER_DELAY: 1000, // Delay before showing tooltip (ms)
POLL_INTERVAL: 5000, // Reduced polling frequency for better performance
BADGE_POSITION: { right: '14px', bottom: '14px' },
BADGE_VISIBLE: true,
DEBUG: false, // Set to true to enable debug logging
// Performance optimizations
POLLING: {
PAUSE_WHEN_HIDDEN: true, // Pause polling timer when tab is hidden
SKIP_WHEN_HIDDEN: true, // Skip polling execution when tab is hidden (lighter)
ADAPTIVE: true // Enable adaptive polling based on activity
},
// Multi-tab coordination
MULTI_TAB: {
ENABLED: false, // Set to true to enable multi-tab cache coordination
SYNC_INTERVAL: 10000 // How often to sync cache across tabs (ms)
},
// Debounce settings for database writes
DEBOUNCE: {
ENABLED: true, // Enable debounced writes for better performance
DELAY: 1000 // Delay in ms before writing to storage
},
// Web Worker for heavy operations
WEB_WORKER: {
ENABLED: true, // Use Web Worker for cleanup operations
TIMEOUT: 15000 // Timeout for worker operations (ms)
},
// URL normalization options
NORMALIZE_URL: {
REMOVE_QUERY: false, // Set to true to ignore query params (?key=value)
// false: tracks "site.com?q=A" and "site.com?q=B" separately
// true: groups them as "site.com" (same page)
REMOVE_HASH: true, // Set to true to ignore hash fragments (#section)
// true: treats "page.html#top" and "page.html#bottom" as same
// false: tracks different sections separately
REMOVE_WWW: true, // Set to true to remove www. prefix
REMOVE_PROTOCOL: true, // Set to true to remove http/https
REMOVE_TRAILING_SLASH: true, // Set to true to remove trailing /
CLEAN_SEARCH_URLS: true // Clean search engine URLs (keep only main query)
},
// URL filtering - Skip tracking certain types of URLs
URL_FILTERS: {
SKIP_UTILITY_PAGES: true, // Skip tracking utility/internal pages (cookies, auth, etc.)
SKIP_PATTERNS: [ // URL patterns to skip (case-insensitive)
'/RotateCookiesPage', // YouTube cookie rotation
'/ServiceLogin', // Google login pages
'/CheckCookie', // Cookie check pages
'/robots.txt', // Robot files
'/favicon.ico', // Favicon requests
'ogs.google.com', // Google widgets/apps
'/widget/app', // Google widget apps
'/persist_identity', // YouTube identity persistence
'studio.youtube.com/persist_identity' // YouTube Studio identity
]
}
};
// Badge visibility state
let badgeVisible = CONFIG.BADGE_VISIBLE;
let menuRegistered = false; // Flag to prevent duplicate menu registration
// Polling state
let pollTimer = null;
let lastHref = location.href;
let lastCheck = Date.now();
let activityCount = 0; // Track recent activity for adaptive polling
// In-memory cache for hot path performance
let dbCache = null;
let cacheValid = false;
function normalizeUrl(url) {
// Validate input URL first
if (!url || typeof url !== 'string') {
console.warn('Invalid URL provided to normalizeUrl:', url);
return location.href;
}
// Configurable URL normalization for flexible tracking granularity
let normalized = url.trim();
// Handle malformed URLs
try {
// Test if URL is valid by creating URL object
new URL(normalized.startsWith('http') ? normalized : 'http://' + normalized);
} catch (error) {
if (CONFIG.DEBUG) {
console.warn('Malformed URL detected, using current location:', url);
}
return normalizeUrl(location.href);
}
// Clean search URLs before other normalizations (must be done first)
if (CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS) {
normalized = cleanSearchUrl(normalized);
}
// Remove protocol if configured
if (CONFIG.NORMALIZE_URL.REMOVE_PROTOCOL) {
normalized = normalized.replace(/^https?:\/\//, '');
}
// Remove www prefix if configured
if (CONFIG.NORMALIZE_URL.REMOVE_WWW) {
normalized = normalized.replace(/^www\./, '');
}
// Remove trailing slash if configured
if (CONFIG.NORMALIZE_URL.REMOVE_TRAILING_SLASH) {
normalized = normalized.replace(/\/$/, '');
}
// Remove hash fragments if configured
if (CONFIG.NORMALIZE_URL.REMOVE_HASH) {
normalized = normalized.split('#')[0];
}
// Remove query parameters if configured (after search cleaning)
if (CONFIG.NORMALIZE_URL.REMOVE_QUERY) {
normalized = normalized.split('?')[0];
}
if (CONFIG.DEBUG) {
console.log(`🔗 URL normalized: "${url}" → "${normalized}"`);
}
return normalized;
}
// Check if URL should be skipped from tracking
function shouldSkipUrl(url) {
if (!CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES) return false;
const urlLower = url.toLowerCase();
// Check against skip patterns
for (const pattern of CONFIG.URL_FILTERS.SKIP_PATTERNS) {
if (urlLower.includes(pattern.toLowerCase())) {
if (CONFIG.DEBUG) {
console.log(`🚫 Skipping URL (matches pattern "${pattern}"): ${url}`);
}
return true;
}
}
return false;
}
// Clean search engine URLs to group similar searches
function cleanSearchUrl(url) {
if (!CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS) return url;
try {
const urlObj = new URL(url.startsWith('http') ? url : 'https://' + url);
const hostname = urlObj.hostname.toLowerCase();
const pathname = urlObj.pathname;
const searchParams = new URLSearchParams(urlObj.search);
// Google Search
if ((hostname.includes('google.') || hostname === 'google.com') && pathname.includes('/search')) {
const query = searchParams.get('q');
if (query) {
// Keep only the main query, remove tracking params
const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}${pathname}?q=${encodeURIComponent(query)}`;
if (CONFIG.DEBUG) {
console.log(`🔍 Cleaned Google search: "${url}" → "${cleanUrl}"`);
}
return cleanUrl;
}
}
// Bing Search
else if (hostname.includes('bing.com') && pathname.includes('/search')) {
const query = searchParams.get('q');
if (query) {
const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}${pathname}?q=${encodeURIComponent(query)}`;
if (CONFIG.DEBUG) {
console.log(`🔍 Cleaned Bing search: "${url}" → "${cleanUrl}"`);
}
return cleanUrl;
}
}
// DuckDuckGo Search
else if (hostname.includes('duckduckgo.com')) {
const query = searchParams.get('q');
if (query) {
const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}/?q=${encodeURIComponent(query)}`;
if (CONFIG.DEBUG) {
console.log(`🔍 Cleaned DuckDuckGo search: "${url}" → "${cleanUrl}"`);
}
return cleanUrl;
}
}
// YouTube Search
else if (hostname.includes('youtube.com') && pathname.includes('/results')) {
const query = searchParams.get('search_query');
if (query) {
const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}${pathname}?search_query=${encodeURIComponent(query)}`;
if (CONFIG.DEBUG) {
console.log(`🔍 Cleaned YouTube search: "${url}" → "${cleanUrl}"`);
}
return cleanUrl;
}
}
} catch (error) {
if (CONFIG.DEBUG) {
console.warn('Failed to clean search URL:', error);
}
}
return url; // Return original if not a search URL or parsing failed
}
// Safe closest() that handles Text nodes and elements without closest method
function safeClosest(target, selector) {
// Handle null/undefined
if (!target) return null;
// If target is a Text node, use its parent element
if (target.nodeType === Node.TEXT_NODE) {
target = target.parentElement;
}
// If target doesn't have closest method (SVG elements in old browsers), fallback
if (!target || typeof target.closest !== 'function') {
// Traverse up manually
let element = target;
while (element && element.nodeType === Node.ELEMENT_NODE) {
if (element.matches && element.matches(selector)) {
return element;
}
element = element.parentElement;
}
return null;
}
// Use native closest if available
return target.closest(selector);
}
// Polling control functions
function directPoll() {
// Skip polling when tab is hidden for performance
if (CONFIG.POLLING.SKIP_WHEN_HIDDEN && document.hidden) {
if (CONFIG.DEBUG) {
console.log('⏸️ Skipping poll - tab is hidden');
}
return;
}
const currentHref = location.href;
const now = Date.now();
// Check if we should process pending URL change
if (pendingUrlChange && !pendingTimeout && (now - lastUrlChangeTime) >= URL_CHANGE_MIN_INTERVAL) {
if (CONFIG.DEBUG) {
console.log(`🔄 Polling processing pending URL change: ${currentUrl} → ${pendingUrlChange}`);
}
const savedPendingUrl = pendingUrlChange;
pendingUrlChange = null;
// Validate URL before processing
if (savedPendingUrl && savedPendingUrl !== currentUrl) {
currentUrl = savedPendingUrl;
lastUrlChangeTime = now;
updateVisit();
}
}
// Only process if URL actually changed and enough time has passed
if (currentHref !== lastHref && (now - lastCheck) >= 1000) {
if (CONFIG.DEBUG) {
console.log(`🔄 Polling detected URL change: ${lastHref} → ${currentHref}`);
}
lastHref = currentHref;
lastCheck = now;
onUrlChange();
} else if (currentHref !== lastHref) {
// URL changed but too soon - just update lastHref to prevent spam
lastHref = currentHref;
}
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
// Adaptive polling interval based on activity
let interval = CONFIG.POLL_INTERVAL;
if (CONFIG.POLLING.ADAPTIVE) {
// More frequent polling if recent activity, less if idle
interval = activityCount > 0 ? Math.max(2000, CONFIG.POLL_INTERVAL / 2) : CONFIG.POLL_INTERVAL * 2;
// Decay activity count over time
activityCount = Math.max(0, activityCount - 1);
}
pollTimer = setInterval(directPoll, interval);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// Optimized functions for timestamp storage
function createTimestamp(date = new Date()) {
return date.getTime();
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
const pad = n => n.toString().padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())} ${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`;
}
// Calculate accurate UTF-8 byte size using Blob
function getActualDataSize(data) {
try {
const jsonString = JSON.stringify(data);
// Create a Blob to get the actual UTF-8 byte size
const blob = new Blob([jsonString], { type: 'application/json' });
return blob.size;
} catch (error) {
// Fallback to character count if Blob fails
console.warn('Failed to calculate Blob size, using character count:', error);
return JSON.stringify(data).length;
}
}
// Smart cleanup to maintain database size with performance optimization
// Uses Web Worker for heavy computation to avoid blocking UI
async function cleanupOldUrls(db) {
const urls = Object.keys(db);
if (urls.length <= CONFIG.MAX_URLS_STORED) return db;
if (CONFIG.DEBUG) {
console.log(`🧹 Large database cleanup: ${urls.length} → ${CONFIG.MAX_URLS_STORED} URLs`);
}
// Cleanup logic - can run in main thread or Web Worker
const performCleanup = (dbData, maxUrls) => {
const urlKeys = Object.keys(dbData);
// Calculate score for each URL (visits * recency)
const scored = urlKeys.map(url => {
const data = dbData[url];
const recentVisit = data.visits?.[0] ?? 0;
const daysSinceVisit = (Date.now() - recentVisit) / (1000 * 60 * 60 * 24);
const recencyScore = Math.max(0, 30 - daysSinceVisit) / 30;
const score = data.count * (1 + recencyScore);
return { url, score };
});
// Keep top URLs by score
scored.sort((a, b) => b.score - a.score);
const keepUrls = scored.slice(0, maxUrls);
// Build clean database
const cleanDb = {};
for (const { url } of keepUrls) {
cleanDb[url] = dbData[url];
}
return cleanDb;
};
// Try Web Worker for large databases
if (CONFIG.WEB_WORKER.ENABLED && urls.length > 5000 && typeof Worker !== 'undefined') {
try {
return await runCleanupInWorker(db, CONFIG.MAX_URLS_STORED);
} catch (error) {
if (CONFIG.DEBUG) {
console.warn('🔧 Web Worker cleanup failed, falling back to main thread:', error);
}
// Fallback to main thread
}
}
// Use requestIdleCallback for medium databases, or run directly for small ones
if (window.requestIdleCallback && urls.length > 3000) {
return new Promise(resolve => {
requestIdleCallback(() => {
resolve(performCleanup(db, CONFIG.MAX_URLS_STORED));
}, { timeout: 10000 });
});
}
return performCleanup(db, CONFIG.MAX_URLS_STORED);
}
// Web Worker implementation for cleanup (runs in separate thread)
function runCleanupInWorker(db, maxUrls) {
return new Promise((resolve, reject) => {
// Create worker code as a Blob (works in userscript context)
const workerCode = `
self.onmessage = function(e) {
const { db, maxUrls } = e.data;
const urls = Object.keys(db);
// Calculate scores
const scored = urls.map(url => {
const data = db[url];
const recentVisit = data.visits?.[0] ?? 0;
const daysSinceVisit = (Date.now() - recentVisit) / (1000 * 60 * 60 * 24);
const recencyScore = Math.max(0, 30 - daysSinceVisit) / 30;
const score = data.count * (1 + recencyScore);
return { url, score };
});
// Sort and keep top URLs
scored.sort((a, b) => b.score - a.score);
const keepUrls = scored.slice(0, maxUrls);
// Build clean database
const cleanDb = {};
for (const { url } of keepUrls) {
cleanDb[url] = db[url];
}
self.postMessage(cleanDb);
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
// Timeout handler
const timeoutId = setTimeout(() => {
worker.terminate();
URL.revokeObjectURL(workerUrl);
reject(new Error('Worker timeout'));
}, CONFIG.WEB_WORKER.TIMEOUT);
worker.onmessage = (e) => {
clearTimeout(timeoutId);
worker.terminate();
URL.revokeObjectURL(workerUrl);
if (CONFIG.DEBUG) {
console.log('🔧 Web Worker cleanup completed successfully');
}
resolve(e.data);
};
worker.onerror = (error) => {
clearTimeout(timeoutId);
worker.terminate();
URL.revokeObjectURL(workerUrl);
reject(error);
};
// Send data to worker
worker.postMessage({ db, maxUrls });
});
}
function shortenNumber(num) {
// Handle edge cases first
if (!Number.isFinite(num)) return '0'; // Handle NaN, Infinity, -Infinity
if (num < 0) return '0'; // Visits can't be negative
if (num === 0) return '0';
// Convert to absolute value and round to avoid floating point issues
const absNum = Math.abs(Math.floor(num));
// Handle very large numbers with appropriate suffixes
if (absNum >= 1_000_000_000) {
return (Math.round(absNum / 100_000_000) / 10) + 'B'; // Billions
}
if (absNum >= 1_000_000) {
return (Math.round(absNum / 100_000) / 10) + 'M'; // Millions
}
if (absNum >= 1_000) {
return (Math.round(absNum / 100) / 10) + 'K'; // Thousands
}
return String(absNum);
}
function getDB() {
// Return cached version if available and valid
if (cacheValid && dbCache !== null) {
return dbCache;
}
try {
dbCache = GM_getValue('visitDB', {});
cacheValid = true;
return dbCache;
} catch (error) {
console.warn('Failed to read visit database:', error);
dbCache = {};
cacheValid = true;
return dbCache;
}
}
// Fast read-only access for hot paths (tooltips, etc)
function getDBCached() {
if (cacheValid && dbCache !== null) {
return dbCache;
}
return getDB(); // Fallback to full load
}
// Debounce state for setDB
let pendingDbWrite = null;
let debounceTimer = null;
// Internal function to actually write to storage
async function _persistDB(db) {
try {
// Auto cleanup if database is getting too large
if (Object.keys(db).length > CONFIG.CLEANUP_THRESHOLD) {
db = await cleanupOldUrls(db);
// Update cache with cleaned data
dbCache = db;
}
// Persist to storage
GM_setValue('visitDB', db);
if (CONFIG.DEBUG) {
console.log('💾 Database persisted to storage');
}
} catch (error) {
console.warn('Failed to save visit database:', error);
// Invalidate cache on save failure
cacheValid = false;
}
}
// Debounced setDB - batches rapid writes
async function setDB(db) {
// Always update cache immediately for fast reads
dbCache = db;
cacheValid = true;
if (CONFIG.DEBOUNCE.ENABLED) {
// Store pending write
pendingDbWrite = db;
// Clear existing timer
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Schedule debounced write
debounceTimer = setTimeout(async () => {
if (pendingDbWrite) {
const dataToWrite = pendingDbWrite;
pendingDbWrite = null;
debounceTimer = null;
await _persistDB(dataToWrite);
}
}, CONFIG.DEBOUNCE.DELAY);
} else {
// No debounce - write immediately
await _persistDB(db);
}
}
// Force flush pending writes (call before page unload)
function flushPendingWrites() {
if (pendingDbWrite) {
if (CONFIG.DEBUG) {
console.log('💾 Flushing pending database writes');
}
// Clear timer and write synchronously
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
try {
GM_setValue('visitDB', pendingDbWrite);
} catch (error) {
console.warn('Failed to flush pending writes:', error);
}
pendingDbWrite = null;
}
}
// Invalidate cache when external changes might occur (multi-tab coordination)
// This function is used when CONFIG.MULTI_TAB.ENABLED is true to ensure
// cache consistency across multiple tabs
function invalidateCache() {
cacheValid = false;
// Use setTimeout to prevent race conditions
setTimeout(() => {
dbCache = null;
}, 0);
}
let currentUrl = normalizeUrl(location.href);
function updateVisit() {
// Skip tracking if current URL matches filter patterns
if (shouldSkipUrl(location.href)) {
if (CONFIG.DEBUG) {
console.log(`🚫 Skipping visit tracking: ${location.href}`);
}
return;
}
const db = getDB();
const now = new Date();
const timestamp = createTimestamp(now);
// Use logical assignment and modern destructuring
db[currentUrl] ??= { count: 0, visits: [] };
const urlData = db[currentUrl];
urlData.count += 1;
urlData.visits.unshift(timestamp);
// Trim visits array if needed
if (urlData.visits.length > CONFIG.MAX_VISITS_STORED) {
urlData.visits.length = CONFIG.MAX_VISITS_STORED;
}
if (CONFIG.DEBUG) {
const isNew = urlData.count === 1;
console.log(isNew
? `🆕 New URL tracked: ${currentUrl}`
: `🔄 URL revisited: ${currentUrl} (${urlData.count} times)`
);
}
setDB(db);
renderBadge(urlData);
// Only register menu once to prevent duplicates
if (!menuRegistered) {
registerMenu();
menuRegistered = true;
}
}
function registerMenu() {
// Register static menu items once to prevent duplicates
GM_registerMenuCommand('⚙️ Settings', openSettingsPanel);
GM_registerMenuCommand('👁️ Toggle Badge', toggleBadgeVisibility);
GM_registerMenuCommand('📊 Export Data', exportData);
GM_registerMenuCommand('📈 Show Statistics', showStatistics);
GM_registerMenuCommand('🗑️ Clear Current Page', clearCurrentPage);
GM_registerMenuCommand('💥 Clear All Data', clearAllData);
GM_registerMenuCommand('🐛 Toggle Debug Mode', toggleDebugMode);
}
function exportData() {
try {
// Use cached DB for export - same data, no extra I/O
const db = getDBCached();
const dataStr = JSON.stringify(db, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `visit-tracker-${new Date().toISOString().split('T')[0]}.json`;
// Safely append to DOM
if (document.body) {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
// Fallback for early DOM state
a.click();
}
URL.revokeObjectURL(url);
} catch (error) {
console.error('Export failed:', error);
alert('Failed to export data: ' + error.message);
}
}
function showStatistics() {
// Use cached DB for statistics - read-only operation
const db = getDBCached();
const urls = Object.keys(db);
const totalUrls = urls.length;
// Handle empty database
if (totalUrls === 0) {
alert('📈 Visit Tracker Statistics\n\n🌐 No websites tracked yet!\n\nStart browsing to collect visit data.');
return;
}
const totalVisits = urls.reduce((sum, url) => sum + db[url].count, 0);
// Find most visited site using optional chaining
const mostVisited = urls.reduce((max, url) =>
db[url].count > (db[max]?.count ?? 0) ? url : max, '');
// Find oldest entry using optional chaining
const oldestEntry = urls.reduce((oldest, url) => {
const visits = db[url].visits;
if (!visits?.length) return oldest;
const lastVisit = visits[visits.length - 1];
const oldestVisits = db[oldest]?.visits;
if (!oldestVisits?.length) return url;
const oldestLastVisit = oldestVisits[oldestVisits.length - 1];
return lastVisit < oldestLastVisit ? url : oldest;
}, '');
const stats = `
📈 Visit Tracker Statistics
🌐 Total websites tracked: ${totalUrls}
👆 Total visits recorded: ${totalVisits}
🏆 Most visited: ${mostVisited} (${db[mostVisited]?.count ?? 0} visits)
⏰ Oldest tracked site: ${oldestEntry}
📅 Current page visits: ${db[currentUrl]?.count ?? 0}
Database size: ${Math.round(getActualDataSize(db) / 1024)} KB (UTF-8)
`.trim();
alert(stats);
}
function clearCurrentPage() {
if (confirm(`Clear visit data for current page?\n\nURL: ${currentUrl}\nThis will only affect this page.`)) {
const db = getDB();
// Clear old data and immediately set new entry in single operation
const now = new Date();
const timestamp = createTimestamp(now);
db[currentUrl] = { count: 1, visits: [timestamp] };
setDB(db);
// Update UI immediately with new data
renderBadge(db[currentUrl]);
alert('Current page data cleared! Counter reset to 1.');
}
}
function clearAllData() {
if (confirm('⚠️ WARNING: This will clear ALL visit data from ALL websites!\n\nAre you absolutely sure?')) {
// Clear all data and immediately create new entry for current page in single operation
const now = new Date();
const timestamp = createTimestamp(now);
const newDb = {};
newDb[currentUrl] = { count: 1, visits: [timestamp] };
setDB(newDb);
// Update UI immediately with new data
renderBadge(newDb[currentUrl]);
alert('All visit data cleared! Current page counter reset to 1.');
}
}
function ensureBadgeStyles() {
if (document.getElementById('vt-hover-styles')) return;
const css = `
.vt-badge {
position: fixed;
right: ${CONFIG.BADGE_POSITION.right};
bottom: ${CONFIG.BADGE_POSITION.bottom};
z-index: 2147483647;
font-family: system-ui, sans-serif;
cursor: pointer;
transition: all 0.3s ease;
}
.vt-badge.hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.8);
}
.vt-link {
display: inline-block;
padding: 6px 10px;
border-radius: 9999px;
background: rgba(20,20,20,0.9);
color: #fff !important;
font-size: 12px;
box-shadow: 0 4px 14px rgba(0,0,0,0.2);
opacity: 0.85;
transition: opacity 0.2s ease;
}
.vt-badge:hover .vt-link { opacity: 1; }
.vt-tooltip {
position: absolute;
bottom: 120%;
right: 0;
background: #111;
color: #fff;
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 10px 25px rgba(0,0,0,0.35);
opacity: 0;
transform: translateY(6px);
transition: opacity 140ms ease, transform 140ms ease;
pointer-events: none;
}
.vt-badge:hover .vt-tooltip {
opacity: 1;
transform: translateY(0);
}
.vt-tooltip .vt-line { display: block; }
`;
const style = document.createElement('style');
style.id = 'vt-hover-styles';
style.textContent = css;
document.documentElement.appendChild(style);
}
function renderBadge(data) {
ensureBadgeStyles();
let badge = document.getElementById('vt-hover-badge');
if (!badge) {
badge = document.createElement('div');
badge.id = 'vt-hover-badge';
badge.className = 'vt-badge';
badge.innerHTML = `
<a class="vt-link" href="javascript:void(0)"></a>
<div class="vt-tooltip"></div>
`;
// Add click handler for toggle visibility
badge.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
toggleBadgeVisibility();
});
document.documentElement.appendChild(badge);
}
// Apply visibility state
if (!badgeVisible) {
badge.classList.add('hidden');
} else {
badge.classList.remove('hidden');
}
badge.querySelector('.vt-link').textContent = `Visit: ${shortenNumber(data.count)}`;
const tooltip = badge.querySelector('.vt-tooltip');
tooltip.innerHTML = `<span class="vt-line">Visit: ${data.count}</span>`;
// Handle empty visits array - format timestamps for display
if (data.visits && data.visits.length > 0) {
data.visits.forEach((timestamp, i) => {
const formattedTime = formatTimestamp(timestamp);
tooltip.innerHTML += `<span class="vt-line">${i + 1}. ${formattedTime}</span>`;
});
} else {
tooltip.innerHTML += `<span class="vt-line">No visit history</span>`;
}
}
function toggleBadgeVisibility() {
badgeVisible = !badgeVisible;
const badge = document.getElementById('vt-hover-badge');
if (badge) {
if (badgeVisible) {
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
// Save state to GM storage
try {
GM_setValue('badgeVisible', badgeVisible);
} catch (error) {
console.warn('Failed to save badge visibility state:', error);
}
}
function toggleUrlFiltering() {
CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES = !CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES;
// Save state to GM storage
try {
GM_setValue('urlFiltering', CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES);
} catch (error) {
console.warn('Failed to save URL filtering state:', error);
}
const status = CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES ? 'enabled' : 'disabled';
alert(`🚫 URL Filtering ${status}!\n\nUtility pages (cookies, auth, etc.) filtering is now ${status}.`);
}
function toggleSearchCleaning() {
CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS = !CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS;
// Save state to GM storage
try {
GM_setValue('searchCleaning', CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS);
} catch (error) {
console.warn('Failed to save search cleaning state:', error);
}
const status = CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS ? 'enabled' : 'disabled';
alert(`🔍 Search URL Cleaning ${status}!\n\nSearch URLs will now ${CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS ? 'be cleaned (grouped by query)' : 'be tracked as-is (separate tracking)'}.`);
}
function toggleDebugMode() {
CONFIG.DEBUG = !CONFIG.DEBUG;
// Save state to GM storage
try {
GM_setValue('debugMode', CONFIG.DEBUG);
} catch (error) {
console.warn('Failed to save debug mode state:', error);
}
const status = CONFIG.DEBUG ? 'enabled' : 'disabled';
alert(`🐛 Debug mode ${status}!\n\nDebug logging is now ${status}.`);
if (CONFIG.DEBUG) {
console.log('🐛 Visit Tracker Debug Mode: ENABLED');
}
}
// ============================================
// SETTINGS PANEL UI
// ============================================
let settingsPanel = null;
let settingsPanelOpen = false;
function getSettingsPanelStyles() {
return `
.vt-settings-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 2147483646;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.vt-settings-overlay.visible {
opacity: 1;
}
.vt-settings-panel {
background: linear-gradient(145deg, #1a1a2e 0%, #16213e 100%);
border-radius: 16px;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
width: 480px;
max-width: 95vw;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
transform: scale(0.9) translateY(20px);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
font-family: system-ui, -apple-system, sans-serif;
}
.vt-settings-overlay.visible .vt-settings-panel {
transform: scale(1) translateY(0);
}
.vt-settings-header {
padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.03);
}
.vt-settings-title {
font-size: 18px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.vt-settings-title::before {
content: '⚙️';
font-size: 20px;
}
.vt-settings-close {
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #fff;
font-size: 18px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.vt-settings-close:hover {
background: rgba(239, 68, 68, 0.8);
transform: scale(1.05);
}
.vt-settings-body {
padding: 16px 24px;
overflow-y: auto;
flex: 1;
}
.vt-settings-section {
margin-bottom: 20px;
}
.vt-settings-section:last-child {
margin-bottom: 0;
}
.vt-section-title {
font-size: 12px;
font-weight: 600;
color: #818cf8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.vt-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
margin-bottom: 8px;
transition: background 0.15s ease;
}
.vt-setting-row:hover {
background: rgba(255, 255, 255, 0.06);
}
.vt-setting-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.vt-setting-name {
font-size: 14px;
color: #e2e8f0;
font-weight: 500;
}
.vt-setting-desc {
font-size: 11px;
color: #64748b;
}
.vt-toggle {
position: relative;
width: 44px;
height: 24px;
background: rgba(255, 255, 255, 0.15);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s ease;
}
.vt-toggle.active {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.vt-toggle::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.vt-toggle.active::after {
transform: translateX(20px);
}
.vt-input-number {
width: 80px;
padding: 6px 10px;
background: rgba(30, 30, 50, 0.9) !important;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #ffffff !important;
font-size: 13px;
font-weight: 500;
text-align: center;
transition: all 0.15s ease;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.vt-input-number::-webkit-outer-spin-button,
.vt-input-number::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.vt-input-number:focus {
outline: none;
border-color: #6366f1;
background: rgba(99, 102, 241, 0.2) !important;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
.vt-settings-footer {
padding: 16px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
gap: 12px;
background: rgba(0, 0, 0, 0.2);
}
.vt-btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.vt-btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
}
.vt-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.vt-btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
.vt-btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.vt-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #fff;
padding: 12px 24px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
z-index: 2147483647;
opacity: 0;
transition: all 0.3s ease;
}
.vt-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
`;
}
function createSettingsPanel() {
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'vt-settings-overlay';
overlay.id = 'vt-settings-overlay';
// Create panel HTML
overlay.innerHTML = `
<div class="vt-settings-panel">
<div class="vt-settings-header">
<div class="vt-settings-title">URL Visit Tracker Settings</div>
<button class="vt-settings-close" id="vt-close-settings">✕</button>
</div>
<div class="vt-settings-body">
<!-- General Section -->
<div class="vt-settings-section">
<div class="vt-section-title">🎯 General</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Show Badge</span>
<span class="vt-setting-desc">Display visit counter badge on screen</span>
</div>
<div class="vt-toggle ${badgeVisible ? 'active' : ''}" data-setting="badgeVisible"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Debug Mode</span>
<span class="vt-setting-desc">Enable console logging for debugging</span>
</div>
<div class="vt-toggle ${CONFIG.DEBUG ? 'active' : ''}" data-setting="debug"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Hover Delay</span>
<span class="vt-setting-desc">Delay before showing tooltip (ms)</span>
</div>
<input type="number" class="vt-input-number" value="${CONFIG.HOVER_DELAY}" data-setting="hoverDelay" min="0" max="5000" step="100">
</div>
</div>
<!-- URL Normalization Section -->
<div class="vt-settings-section">
<div class="vt-section-title">🔗 URL Normalization</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Remove Query Params</span>
<span class="vt-setting-desc">Ignore ?key=value in URLs</span>
</div>
<div class="vt-toggle ${CONFIG.NORMALIZE_URL.REMOVE_QUERY ? 'active' : ''}" data-setting="removeQuery"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Remove Hash</span>
<span class="vt-setting-desc">Ignore #section in URLs</span>
</div>
<div class="vt-toggle ${CONFIG.NORMALIZE_URL.REMOVE_HASH ? 'active' : ''}" data-setting="removeHash"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Clean Search URLs</span>
<span class="vt-setting-desc">Group search results by query</span>
</div>
<div class="vt-toggle ${CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS ? 'active' : ''}" data-setting="cleanSearchUrls"></div>
</div>
</div>
<!-- URL Filtering Section -->
<div class="vt-settings-section">
<div class="vt-section-title">🚫 URL Filtering</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Skip Utility Pages</span>
<span class="vt-setting-desc">Don't track login, cookie pages, etc.</span>
</div>
<div class="vt-toggle ${CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES ? 'active' : ''}" data-setting="skipUtilityPages"></div>
</div>
</div>
<!-- Performance Section -->
<div class="vt-settings-section">
<div class="vt-section-title">⚡ Performance</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Debounce Writes</span>
<span class="vt-setting-desc">Batch database writes for better performance</span>
</div>
<div class="vt-toggle ${CONFIG.DEBOUNCE.ENABLED ? 'active' : ''}" data-setting="debounceEnabled"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Debounce Delay</span>
<span class="vt-setting-desc">Delay before writing to storage (ms)</span>
</div>
<input type="number" class="vt-input-number" value="${CONFIG.DEBOUNCE.DELAY}" data-setting="debounceDelay" min="100" max="5000" step="100">
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Use Web Worker</span>
<span class="vt-setting-desc">Run cleanup in background thread</span>
</div>
<div class="vt-toggle ${CONFIG.WEB_WORKER.ENABLED ? 'active' : ''}" data-setting="webWorkerEnabled"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Pause When Hidden</span>
<span class="vt-setting-desc">Stop polling when tab is not visible</span>
</div>
<div class="vt-toggle ${CONFIG.POLLING.PAUSE_WHEN_HIDDEN ? 'active' : ''}" data-setting="pauseWhenHidden"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Adaptive Polling</span>
<span class="vt-setting-desc">Adjust polling frequency based on activity</span>
</div>
<div class="vt-toggle ${CONFIG.POLLING.ADAPTIVE ? 'active' : ''}" data-setting="adaptivePolling"></div>
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Poll Interval</span>
<span class="vt-setting-desc">URL change check interval (ms)</span>
</div>
<input type="number" class="vt-input-number" value="${CONFIG.POLL_INTERVAL}" data-setting="pollInterval" min="1000" max="30000" step="1000">
</div>
</div>
<!-- Storage Section -->
<div class="vt-settings-section">
<div class="vt-section-title">💾 Storage</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Max URLs Stored</span>
<span class="vt-setting-desc">Maximum number of URLs to track</span>
</div>
<input type="number" class="vt-input-number" value="${CONFIG.MAX_URLS_STORED}" data-setting="maxUrlsStored" min="1000" max="50000" step="1000">
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Max Visits per URL</span>
<span class="vt-setting-desc">Visit timestamps to keep per URL</span>
</div>
<input type="number" class="vt-input-number" value="${CONFIG.MAX_VISITS_STORED}" data-setting="maxVisitsStored" min="5" max="100" step="5">
</div>
<div class="vt-setting-row">
<div class="vt-setting-label">
<span class="vt-setting-name">Multi-Tab Sync</span>
<span class="vt-setting-desc">Sync data across browser tabs</span>
</div>
<div class="vt-toggle ${CONFIG.MULTI_TAB.ENABLED ? 'active' : ''}" data-setting="multiTabEnabled"></div>
</div>
</div>
</div>
<div class="vt-settings-footer">
<button class="vt-btn vt-btn-secondary" id="vt-reset-settings">Reset to Defaults</button>
<button class="vt-btn vt-btn-primary" id="vt-save-settings">Save Settings</button>
</div>
</div>
`;
return overlay;
}
function openSettingsPanel() {
if (settingsPanelOpen) return;
// Add styles if not already added
if (!document.getElementById('vt-settings-styles')) {
const style = document.createElement('style');
style.id = 'vt-settings-styles';
style.textContent = getSettingsPanelStyles();
document.head.appendChild(style);
}
// Create and add panel
settingsPanel = createSettingsPanel();
document.body.appendChild(settingsPanel);
settingsPanelOpen = true;
// Trigger animation
requestAnimationFrame(() => {
settingsPanel.classList.add('visible');
});
// Add event listeners
setupSettingsEventListeners();
}
// ESC key handler for settings panel (moved outside for proper cleanup)
let settingsEscHandler = null;
function closeSettingsPanel() {
if (!settingsPanel || !settingsPanelOpen) return;
// Remove ESC handler to prevent memory leak
if (settingsEscHandler) {
document.removeEventListener('keydown', settingsEscHandler);
settingsEscHandler = null;
}
settingsPanel.classList.remove('visible');
setTimeout(() => {
if (settingsPanel && settingsPanel.parentNode) {
settingsPanel.parentNode.removeChild(settingsPanel);
}
settingsPanel = null;
settingsPanelOpen = false;
}, 200);
}
function setupSettingsEventListeners() {
// Close button
document.getElementById('vt-close-settings').addEventListener('click', closeSettingsPanel);
// Click outside to close
settingsPanel.addEventListener('click', (e) => {
if (e.target === settingsPanel) {
closeSettingsPanel();
}
});
// ESC key to close - use the outer variable for proper cleanup
settingsEscHandler = (e) => {
if (e.key === 'Escape' && settingsPanelOpen) {
closeSettingsPanel();
}
};
document.addEventListener('keydown', settingsEscHandler);
// Toggle switches
settingsPanel.querySelectorAll('.vt-toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
toggle.classList.toggle('active');
});
});
// Save button
document.getElementById('vt-save-settings').addEventListener('click', saveSettings);
// Reset button
document.getElementById('vt-reset-settings').addEventListener('click', resetSettings);
}
function saveSettings() {
try {
// Read all settings from UI
const getToggle = (name) => settingsPanel.querySelector(`[data-setting="${name}"]`).classList.contains('active');
const getNumber = (name) => parseInt(settingsPanel.querySelector(`[data-setting="${name}"]`).value, 10);
// Update CONFIG
badgeVisible = getToggle('badgeVisible');
CONFIG.DEBUG = getToggle('debug');
CONFIG.HOVER_DELAY = getNumber('hoverDelay');
CONFIG.NORMALIZE_URL.REMOVE_QUERY = getToggle('removeQuery');
CONFIG.NORMALIZE_URL.REMOVE_HASH = getToggle('removeHash');
CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS = getToggle('cleanSearchUrls');
CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES = getToggle('skipUtilityPages');
CONFIG.DEBOUNCE.ENABLED = getToggle('debounceEnabled');
CONFIG.DEBOUNCE.DELAY = getNumber('debounceDelay');
CONFIG.WEB_WORKER.ENABLED = getToggle('webWorkerEnabled');
CONFIG.POLLING.PAUSE_WHEN_HIDDEN = getToggle('pauseWhenHidden');
CONFIG.POLLING.ADAPTIVE = getToggle('adaptivePolling');
CONFIG.POLL_INTERVAL = getNumber('pollInterval');
CONFIG.MAX_URLS_STORED = getNumber('maxUrlsStored');
CONFIG.MAX_VISITS_STORED = getNumber('maxVisitsStored');
CONFIG.MULTI_TAB.ENABLED = getToggle('multiTabEnabled');
// Persist to GM storage
GM_setValue('badgeVisible', badgeVisible);
GM_setValue('debugMode', CONFIG.DEBUG);
GM_setValue('hoverDelay', CONFIG.HOVER_DELAY);
GM_setValue('removeQuery', CONFIG.NORMALIZE_URL.REMOVE_QUERY);
GM_setValue('removeHash', CONFIG.NORMALIZE_URL.REMOVE_HASH);
GM_setValue('searchCleaning', CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS);
GM_setValue('urlFiltering', CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES);
GM_setValue('debounceEnabled', CONFIG.DEBOUNCE.ENABLED);
GM_setValue('debounceDelay', CONFIG.DEBOUNCE.DELAY);
GM_setValue('webWorkerEnabled', CONFIG.WEB_WORKER.ENABLED);
GM_setValue('pauseWhenHidden', CONFIG.POLLING.PAUSE_WHEN_HIDDEN);
GM_setValue('adaptivePolling', CONFIG.POLLING.ADAPTIVE);
GM_setValue('pollInterval', CONFIG.POLL_INTERVAL);
GM_setValue('maxUrlsStored', CONFIG.MAX_URLS_STORED);
GM_setValue('maxVisitsStored', CONFIG.MAX_VISITS_STORED);
GM_setValue('multiTabEnabled', CONFIG.MULTI_TAB.ENABLED);
// Update badge visibility
const badge = document.getElementById('vt-hover-badge');
if (badge) {
badge.classList.toggle('hidden', !badgeVisible);
}
// Restart polling with new settings
stopPolling();
startPolling();
// Show success toast
showToast('✅ Settings saved successfully!');
// Close panel
closeSettingsPanel();
} catch (error) {
console.error('Failed to save settings:', error);
showToast('❌ Failed to save settings');
}
}
function resetSettings() {
if (!confirm('Reset all settings to default values?')) return;
// Default values
const defaults = {
badgeVisible: true,
debug: false,
hoverDelay: 1000,
removeQuery: false,
removeHash: true,
cleanSearchUrls: true,
skipUtilityPages: true,
debounceEnabled: true,
debounceDelay: 1000,
webWorkerEnabled: true,
pauseWhenHidden: true,
adaptivePolling: true,
pollInterval: 5000,
maxUrlsStored: 10000,
maxVisitsStored: 20,
multiTabEnabled: false
};
// Update UI
Object.entries(defaults).forEach(([key, value]) => {
const element = settingsPanel.querySelector(`[data-setting="${key}"]`);
if (element) {
if (element.classList.contains('vt-toggle')) {
element.classList.toggle('active', value);
} else {
element.value = value;
}
}
});
showToast('🔄 Settings reset to defaults');
}
function showToast(message) {
// Remove existing toast
const existingToast = document.querySelector('.vt-toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = 'vt-toast';
toast.textContent = message;
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('visible');
});
setTimeout(() => {
toast.classList.remove('visible');
setTimeout(() => toast.remove(), 300);
}, 2500);
}
// Load all saved settings on initialization
function loadSavedSettings() {
try {
badgeVisible = GM_getValue('badgeVisible', CONFIG.BADGE_VISIBLE);
CONFIG.DEBUG = GM_getValue('debugMode', CONFIG.DEBUG);
CONFIG.HOVER_DELAY = GM_getValue('hoverDelay', CONFIG.HOVER_DELAY);
CONFIG.NORMALIZE_URL.REMOVE_QUERY = GM_getValue('removeQuery', CONFIG.NORMALIZE_URL.REMOVE_QUERY);
CONFIG.NORMALIZE_URL.REMOVE_HASH = GM_getValue('removeHash', CONFIG.NORMALIZE_URL.REMOVE_HASH);
CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS = GM_getValue('searchCleaning', CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS);
CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES = GM_getValue('urlFiltering', CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES);
CONFIG.DEBOUNCE.ENABLED = GM_getValue('debounceEnabled', CONFIG.DEBOUNCE.ENABLED);
CONFIG.DEBOUNCE.DELAY = GM_getValue('debounceDelay', CONFIG.DEBOUNCE.DELAY);
CONFIG.WEB_WORKER.ENABLED = GM_getValue('webWorkerEnabled', CONFIG.WEB_WORKER.ENABLED);
CONFIG.POLLING.PAUSE_WHEN_HIDDEN = GM_getValue('pauseWhenHidden', CONFIG.POLLING.PAUSE_WHEN_HIDDEN);
CONFIG.POLLING.ADAPTIVE = GM_getValue('adaptivePolling', CONFIG.POLLING.ADAPTIVE);
CONFIG.POLL_INTERVAL = GM_getValue('pollInterval', CONFIG.POLL_INTERVAL);
CONFIG.MAX_URLS_STORED = GM_getValue('maxUrlsStored', CONFIG.MAX_URLS_STORED);
CONFIG.MAX_VISITS_STORED = GM_getValue('maxVisitsStored', CONFIG.MAX_VISITS_STORED);
CONFIG.MULTI_TAB.ENABLED = GM_getValue('multiTabEnabled', CONFIG.MULTI_TAB.ENABLED);
} catch (error) {
console.warn('Failed to load saved settings:', error);
}
}
// Rate limiting for URL changes with pending mechanism
let lastUrlChangeTime = 0;
let pendingUrlChange = null;
let pendingTimeout = null;
const URL_CHANGE_MIN_INTERVAL = 500; // Minimum 500ms between URL changes
function onUrlChange() {
const newUrl = normalizeUrl(location.href);
if (newUrl === currentUrl) return;
// Skip tracking if URL matches filter patterns
if (shouldSkipUrl(location.href)) {
if (CONFIG.DEBUG) {
console.log(`🚫 Skipping URL tracking: ${location.href}`);
}
return;
}
// Increment activity counter for adaptive polling
if (CONFIG.POLLING.ADAPTIVE) {
activityCount = Math.min(10, activityCount + 2); // Cap at 10, add 2 for URL change
}
const now = Date.now();
const timeSinceLastChange = now - lastUrlChangeTime;
if (timeSinceLastChange < URL_CHANGE_MIN_INTERVAL) {
if (CONFIG.DEBUG) {
console.log(`⏰ URL change rate limited, scheduling: ${currentUrl} → ${newUrl}`);
}
// Store the pending change without updating currentUrl yet
pendingUrlChange = newUrl;
// Clear any existing pending timeout
if (pendingTimeout) {
clearTimeout(pendingTimeout);
}
// Schedule the change for when rate limit expires
const remainingTime = URL_CHANGE_MIN_INTERVAL - timeSinceLastChange;
pendingTimeout = setTimeout(() => {
if (pendingUrlChange && pendingUrlChange !== currentUrl) {
if (CONFIG.DEBUG) {
console.log(`⏰ Processing pending URL change: ${currentUrl} → ${pendingUrlChange}`);
}
const savedPendingUrl = pendingUrlChange;
pendingUrlChange = null;
pendingTimeout = null;
// Process the pending change
currentUrl = savedPendingUrl;
lastUrlChangeTime = Date.now();
updateVisit();
}
}, remainingTime + 10); // +10ms buffer
return;
}
// Clear any pending changes since we're processing immediately
if (pendingTimeout) {
clearTimeout(pendingTimeout);
pendingTimeout = null;
pendingUrlChange = null;
}
if (CONFIG.DEBUG) {
console.log(`🌐 URL changed: ${currentUrl} → ${newUrl}`);
}
currentUrl = newUrl;
lastUrlChangeTime = now;
updateVisit();
}
function installUrlObservers() {
// Enhanced history hooks with rate limiting
const _pushState = history.pushState;
const _replaceState = history.replaceState;
history.pushState = function (...args) {
const result = _pushState.apply(this, args);
// Use setTimeout to avoid immediate execution conflicts
setTimeout(onUrlChange, 50);
return result;
};
history.replaceState = function (...args) {
const result = _replaceState.apply(this, args);
setTimeout(onUrlChange, 50);
return result;
};
// Standard event listeners
window.addEventListener('popstate', onUrlChange);
window.addEventListener('hashchange', onUrlChange);
// Optimized MutationObserver with focused title tracking
let mutationTimeout = null;
const mo = new MutationObserver((mutations) => {
// Throttle mutation processing to avoid spam
if (mutationTimeout) return;
mutationTimeout = setTimeout(() => {
mutationTimeout = null;
let titleChanged = false;
for (const mutation of mutations) {
// Only check mutations that could affect title
if (mutation.type === 'childList') {
// Case 1: Title element added/removed from head
const titleInAdded = Array.from(mutation.addedNodes).some(node =>
node.nodeName === 'TITLE'
);
const titleInRemoved = Array.from(mutation.removedNodes).some(node =>
node.nodeName === 'TITLE'
);
// Case 2: Direct title element changes
if (mutation.target.nodeName === 'TITLE') {
titleChanged = true;
if (CONFIG.DEBUG) {
console.log('📝 Title childList mutation detected:', mutation);
}
break;
}
if (titleInAdded || titleInRemoved) {
titleChanged = true;
if (CONFIG.DEBUG) {
console.log('📝 Title element added/removed:', mutation);
}
break;
}
}
// Case 3: Character data changed in title's text nodes (more targeted)
else if (mutation.type === 'characterData' &&
mutation.target.parentNode?.nodeName === 'TITLE') {
titleChanged = true;
if (CONFIG.DEBUG) {
console.log('📝 Title characterData mutation detected:', mutation);
}
break;
}
}
if (titleChanged) {
if (CONFIG.DEBUG) {
console.log('📝 Title change detected, triggering URL change check');
}
onUrlChange();
}
}, 150); // Slightly increased debounce for better performance
});
// Safely observe document.head with focused title tracking
if (document.head) {
mo.observe(document.head, {
childList: true, // Detect title element addition/removal
subtree: false, // Only direct children for better performance
characterData: false // Handle characterData separately for title only
});
// Separate observer for title content changes
const titleEl = document.querySelector('title');
if (titleEl) {
mo.observe(titleEl, {
childList: true,
characterData: true,
subtree: true
});
}
} else {
// Fallback: observe document for head creation (minimal scope)
mo.observe(document, {
childList: true,
subtree: false
});
}
// Initialize polling
startPolling();
}
// Lazy tooltip initialization - only create when first needed
let tooltip = null;
let tooltipInitialized = false;
// Create and initialize tooltip element (called lazily on first hover)
function initializeTooltip() {
if (tooltipInitialized) return tooltip;
tooltip = document.createElement('div');
// Apply styles using individual properties for better compatibility
Object.assign(tooltip.style, {
position: 'fixed',
padding: '6px 8px',
fontSize: '12px',
fontFamily: 'system-ui, sans-serif',
background: 'rgba(20, 20, 20, 0.9)',
color: 'white',
borderRadius: '6px',
pointerEvents: 'none',
whiteSpace: 'nowrap',
zIndex: '999999',
opacity: '0',
transition: 'opacity 0.15s ease'
});
// Append to DOM
if (document.body) {
try {
document.body.appendChild(tooltip);
tooltipInitialized = true;
if (CONFIG.DEBUG) {
console.log('📋 Tooltip initialized lazily on first hover');
}
} catch (error) {
console.warn('Failed to append tooltip to body:', error);
}
} else {
// Fallback for early DOM state (shouldn't happen with lazy init)
document.addEventListener('DOMContentLoaded', () => {
try {
if (document.body && !document.body.contains(tooltip)) {
document.body.appendChild(tooltip);
tooltipInitialized = true;
}
} catch (error) {
console.warn('Failed to append tooltip on DOMContentLoaded:', error);
}
}, { passive: true, once: true });
}
return tooltip;
}
// Get tooltip element, initializing if needed
function getTooltip() {
if (!tooltipInitialized) {
initializeTooltip();
}
return tooltip;
}
let hoverTimer;
let currentHoveredLink = null;
let rafId = null; // RequestAnimationFrame ID for smooth tooltip movement
let pendingTooltipPosition = null; // Store pending position updates
let tooltipAutoHideTimer = null; // Auto-hide timer to prevent stuck tooltips
let tooltipValidationTimer = null; // Timer to validate tooltip state
let lastMousePosition = { x: 0, y: 0 }; // Track last mouse position
// Configuration for tooltip anti-stick measures
const TOOLTIP_CONFIG = {
AUTO_HIDE_DELAY: 10000, // Auto-hide after 10 seconds
VALIDATION_INTERVAL: 500, // Check every 500ms if tooltip should still be visible
STALE_THRESHOLD: 2000 // Consider tooltip stale if no mouse movement for 2s
};
function showTooltip(e, linkUrl) {
// Initialize tooltip lazily on first use
const tip = getTooltip();
if (!tip) return; // Safety check
const key = normalizeUrl(linkUrl);
// Use cached DB for hot path performance - no storage I/O!
const db = getDBCached();
const data = db[key];
// Clear previous content safely
tip.textContent = '';
if (!data) {
tip.textContent = 'No visits recorded';
} else {
// Create elements safely instead of using innerHTML
const visitLine = document.createElement('div');
visitLine.textContent = `Visit: ${shortenNumber(data.count)}`;
const lastLine = document.createElement('div');
// Format timestamp for display using optional chaining
const lastVisit = data.visits?.[0] ? formatTimestamp(data.visits[0]) : 'Never';
lastLine.textContent = `Last: ${lastVisit}`;
tip.appendChild(visitLine);
tip.appendChild(lastLine);
}
// Set initial position
updateTooltipPosition(e.clientX, e.clientY);
tip.style.opacity = 1;
// Start auto-hide timer as safety net
startAutoHideTimer();
// Start validation timer to check if tooltip should still be visible
startValidationTimer();
}
// Auto-hide timer - safety net to prevent stuck tooltips
function startAutoHideTimer() {
clearAutoHideTimer();
tooltipAutoHideTimer = setTimeout(() => {
if (CONFIG.DEBUG) {
console.log('⏰ Tooltip auto-hide triggered after timeout');
}
hideTooltip();
}, TOOLTIP_CONFIG.AUTO_HIDE_DELAY);
}
function clearAutoHideTimer() {
if (tooltipAutoHideTimer) {
clearTimeout(tooltipAutoHideTimer);
tooltipAutoHideTimer = null;
}
}
// Validation timer - periodically check if tooltip should still be visible
function startValidationTimer() {
clearValidationTimer();
tooltipValidationTimer = setInterval(() => {
if (!validateTooltipState()) {
if (CONFIG.DEBUG) {
console.log('🔍 Tooltip validation failed, hiding tooltip');
}
hideTooltip();
}
}, TOOLTIP_CONFIG.VALIDATION_INTERVAL);
}
function clearValidationTimer() {
if (tooltipValidationTimer) {
clearInterval(tooltipValidationTimer);
tooltipValidationTimer = null;
}
}
// Validate if tooltip should still be visible
function validateTooltipState() {
// No link being tracked - tooltip shouldn't be visible
if (!currentHoveredLink) {
return false;
}
// Link was removed from DOM
if (!document.body.contains(currentHoveredLink)) {
if (CONFIG.DEBUG) {
console.log('🔗 Link removed from DOM, invalidating tooltip');
}
return false;
}
// Check if mouse is still over the link using elementFromPoint
const elementAtMouse = document.elementFromPoint(lastMousePosition.x, lastMousePosition.y);
if (elementAtMouse) {
const linkAtMouse = safeClosest(elementAtMouse, 'a[href]');
if (linkAtMouse !== currentHoveredLink) {
if (CONFIG.DEBUG) {
console.log('🔗 Mouse no longer over tracked link');
}
return false;
}
}
return true;
}
function updateTooltipPosition(x, y) {
// Store the position to be updated in the next frame
pendingTooltipPosition = { x: x + 12, y: y + 12 };
// Cancel previous frame if it exists
if (rafId) {
cancelAnimationFrame(rafId);
}
// Schedule position update for next frame
rafId = requestAnimationFrame(() => {
if (pendingTooltipPosition) {
const tip = getTooltip();
if (tip) {
tip.style.left = pendingTooltipPosition.x + 'px';
tip.style.top = pendingTooltipPosition.y + 'px';
}
pendingTooltipPosition = null;
}
rafId = null;
});
}
function hideTooltip() {
const tip = getTooltip();
if (tip) {
tip.style.opacity = 0;
}
currentHoveredLink = null;
// Cancel any pending animation frame
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
pendingTooltipPosition = null;
// Clear all timers
clearAutoHideTimer();
clearValidationTimer();
// Ensure mousemove listener is properly removed
document.removeEventListener('mousemove', moveTooltip);
}
function moveTooltip(e) {
// Track mouse position for validation
lastMousePosition.x = e.clientX;
lastMousePosition.y = e.clientY;
// Reset auto-hide timer on mouse movement (user is still active)
startAutoHideTimer();
// Use requestAnimationFrame for smooth movement
updateTooltipPosition(e.clientX, e.clientY);
}
// Improved mouse event handling to prevent tooltip flicker
// Using passive listeners for better performance on heavy pages
document.addEventListener('mouseover', e => {
const a = safeClosest(e.target, 'a[href]');
if (!a) return;
const href = a.href;
if (!/^https?:\/\//.test(href)) return;
// Prevent duplicate listeners for same link
if (currentHoveredLink === a) return;
// Clean up previous link if any
if (currentHoveredLink) {
clearTimeout(hoverTimer);
hideTooltip();
}
currentHoveredLink = a;
// Track initial mouse position
lastMousePosition.x = e.clientX;
lastMousePosition.y = e.clientY;
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => showTooltip(e, href), CONFIG.HOVER_DELAY);
document.addEventListener('mousemove', moveTooltip, { passive: true });
}, { passive: true });
// Use mouseout with relatedTarget check to prevent flicker from child elements
document.addEventListener('mouseout', e => {
const a = safeClosest(e.target, 'a[href]');
if (!a || a !== currentHoveredLink) return;
// Check if we're moving to a child element of the same link
const relatedTarget = e.relatedTarget;
if (relatedTarget && a.contains(relatedTarget)) {
if (CONFIG.DEBUG) {
console.log('🔗 Mouse moved to child element, keeping tooltip visible');
}
return; // Still within the same link, don't hide tooltip
}
// Also check if we're moving from child to parent within same link
const relatedLink = safeClosest(relatedTarget, 'a[href]');
if (relatedLink === a) {
if (CONFIG.DEBUG) {
console.log('🔗 Mouse moved within same link structure, keeping tooltip visible');
}
return; // Still within the same link structure
}
if (CONFIG.DEBUG) {
console.log('🔗 Mouse left link, hiding tooltip');
}
clearTimeout(hoverTimer);
hideTooltip();
}, { passive: true });
// Additional safety: hide tooltip when clicking anywhere
document.addEventListener('click', () => {
if (currentHoveredLink) {
clearTimeout(hoverTimer);
hideTooltip();
}
}, { passive: true });
// Additional safety: hide tooltip when scrolling
document.addEventListener('scroll', () => {
if (currentHoveredLink) {
clearTimeout(hoverTimer);
hideTooltip();
}
}, { passive: true, capture: true });
// Initialize the tracker
function initializeTracker() {
// Load all saved settings
loadSavedSettings();
if (CONFIG.DEBUG) {
console.log('🐛 Visit Tracker Debug Mode: ENABLED');
}
// Don't register menu for initial empty state - let updateVisit() handle it
updateVisit();
installUrlObservers();
// Handle polling optimization and cache invalidation for multi-tab scenarios
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Tab became hidden - pause polling if configured
if (CONFIG.POLLING.PAUSE_WHEN_HIDDEN) {
stopPolling();
}
// Hide tooltip when tab is hidden to prevent stuck tooltips
if (currentHoveredLink) {
clearTimeout(hoverTimer);
hideTooltip();
}
} else {
// Tab became visible - resume polling if it was paused
if (CONFIG.POLLING.PAUSE_WHEN_HIDDEN && !pollTimer) {
// Boost activity for immediate responsiveness when tab becomes visible
if (CONFIG.POLLING.ADAPTIVE) {
activityCount = Math.min(10, activityCount + 3);
}
startPolling();
}
// Multi-tab cache coordination if enabled
if (CONFIG.MULTI_TAB.ENABLED) {
invalidateCache();
}
}
}, { passive: true });
// Multi-tab cache synchronization if enabled
if (CONFIG.MULTI_TAB.ENABLED) {
setInterval(() => {
if (!document.hidden) {
invalidateCache();
}
}, CONFIG.MULTI_TAB.SYNC_INTERVAL);
}
}
// Cleanup pending operations on page unload
window.addEventListener('beforeunload', () => {
// Flush any pending debounced database writes
flushPendingWrites();
if (pendingTimeout) {
clearTimeout(pendingTimeout);
// Process any pending URL change immediately before unload
if (pendingUrlChange && pendingUrlChange !== currentUrl) {
currentUrl = pendingUrlChange;
updateVisit();
// Flush the new write as well
flushPendingWrites();
}
}
});
initializeTracker();
})();