/** * Strava Unfollowers Tool * Find who doesn't follow you back on Strava * * Usage: * 1. Go to https://www.strava.com and log in * 2. Open browser console (F12 or Cmd+Option+J on Mac) * 3. Paste this entire script and press Enter */ (function() { 'use strict'; // Check if we're on Strava if (location.hostname !== 'www.strava.com') { alert('Please run this script on www.strava.com'); return; } // ============================================ // STYLES // ============================================ const STYLES = ` @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap'); .su-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(135deg, #0d1117 0%, #161b22 50%, #1a1f25 100%); z-index: 999999; overflow: auto; font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; } .su-container { max-width: 1400px; margin: 0 auto; min-height: 100vh; display: flex; flex-direction: column; } .su-header { position: sticky; top: 0; background: rgba(13, 17, 23, 0.95); backdrop-filter: blur(20px); border-bottom: 1px solid rgba(252, 82, 0, 0.2); padding: 1rem 2rem; z-index: 100; display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; } .su-logo { display: flex; align-items: center; gap: 0.75rem; cursor: pointer; } .su-logo-text { display: flex; flex-direction: column; font-family: 'Space Mono', monospace; } .su-logo-text .title { font-size: 1.25rem; font-weight: 700; color: #fc5200; } .su-logo-text .subtitle { font-size: 0.7rem; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 2px; } .su-header-actions { display: flex; align-items: center; gap: 1rem; } .su-btn { padding: 0.6rem 1.2rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: all 0.2s ease; border: none; display: inline-flex; align-items: center; gap: 0.5rem; } .su-btn-primary { background: linear-gradient(135deg, #fc5200 0%, #ff7033 100%); color: white; } .su-btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(252, 82, 0, 0.4); } .su-btn-secondary { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); } .su-btn-secondary:hover { background: rgba(255,255,255,0.15); } .su-btn-danger { background: linear-gradient(135deg, #dc3545 0%, #ff4757 100%); color: white; } .su-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; } .su-close-btn { background: rgba(255,255,255,0.1); border: none; color: white; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; font-size: 1.5rem; display: flex; align-items: center; justify-content: center; } .su-close-btn:hover { background: rgba(255,255,255,0.2); } .su-main { display: flex; flex: 1; gap: 0; } .su-sidebar { width: 320px; background: rgba(0,0,0,0.3); border-right: 1px solid rgba(255,255,255,0.1); padding: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; } .su-content { flex: 1; padding: 1.5rem 2rem; } .su-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .su-stat-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 1rem; text-align: center; } .su-stat-value { font-size: 2rem; font-weight: 700; color: #fc5200; font-family: 'Space Mono', monospace; } .su-stat-label { font-size: 0.75rem; color: rgba(255,255,255,0.6); text-transform: uppercase; letter-spacing: 1px; margin-top: 0.25rem; } .su-tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 1rem; } .su-tab { padding: 0.75rem 1.25rem; border-radius: 8px; background: transparent; border: none; color: rgba(255,255,255,0.6); cursor: pointer; font-weight: 500; transition: all 0.2s; } .su-tab:hover { background: rgba(255,255,255,0.05); color: white; } .su-tab.active { background: rgba(252, 82, 0, 0.2); color: #fc5200; } .su-search { width: 100%; padding: 0.75rem 1rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(0,0,0,0.3); color: white; font-size: 0.9rem; } .su-search::placeholder { color: rgba(255,255,255,0.4); } .su-search:focus { outline: none; border-color: #fc5200; } .su-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } .su-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 1rem; display: flex; align-items: center; gap: 1rem; cursor: pointer; transition: all 0.2s; border: 2px solid transparent; } .su-card:hover { background: rgba(255,255,255,0.08); transform: translateY(-2px); } .su-card.selected { border-color: #fc5200; background: rgba(252, 82, 0, 0.1); } .su-card.whitelisted { border-color: #28a745; background: rgba(40, 167, 69, 0.1); } .su-avatar { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; background: rgba(255,255,255,0.1); } .su-card-info { flex: 1; min-width: 0; } .su-card-name { font-weight: 600; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .su-card-location { font-size: 0.8rem; color: rgba(255,255,255,0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .su-premium-badge { background: linear-gradient(135deg, #fc5200, #ff7033); color: white; font-size: 0.65rem; padding: 0.2rem 0.5rem; border-radius: 4px; font-weight: 600; text-transform: uppercase; } .su-checkbox { width: 20px; height: 20px; border-radius: 4px; border: 2px solid rgba(255,255,255,0.3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .su-card.selected .su-checkbox { background: #fc5200; border-color: #fc5200; } .su-card.selected .su-checkbox::after { content: '✓'; color: white; font-size: 0.8rem; } .su-progress-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-top: 1rem; } .su-progress-fill { height: 100%; background: linear-gradient(90deg, #fc5200, #ff7033); transition: width 0.3s ease; } .su-toast { position: fixed; bottom: 2rem; right: 2rem; padding: 1rem 1.5rem; border-radius: 12px; color: white; font-weight: 500; z-index: 1000001; animation: slideIn 0.3s ease; max-width: 400px; } .su-toast.success { background: linear-gradient(135deg, #28a745, #34ce57); } .su-toast.error { background: linear-gradient(135deg, #dc3545, #ff4757); } .su-toast.warning { background: linear-gradient(135deg, #ffc107, #ffca2c); color: #000; } .su-toast.info { background: linear-gradient(135deg, #17a2b8, #20c9e0); } @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } .su-empty { text-align: center; padding: 4rem 2rem; color: rgba(255,255,255,0.5); } .su-empty-icon { font-size: 4rem; margin-bottom: 1rem; } .su-section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; color: rgba(255,255,255,0.5); margin-bottom: 0.75rem; } .su-bulk-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .su-bulk-btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; border-radius: 6px; background: rgba(255,255,255,0.1); border: none; color: white; cursor: pointer; } .su-bulk-btn:hover { background: rgba(255,255,255,0.15); } .su-initial-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; text-align: center; padding: 2rem; } .su-initial-icon { font-size: 5rem; margin-bottom: 1.5rem; } .su-initial-title { font-size: 2rem; font-weight: 700; color: white; margin-bottom: 0.5rem; } .su-initial-subtitle { color: rgba(255,255,255,0.6); margin-bottom: 2rem; max-width: 500px; } .su-scanning-status { text-align: center; padding: 2rem; } .su-scanning-spinner { width: 60px; height: 60px; border: 4px solid rgba(252, 82, 0, 0.2); border-top-color: #fc5200; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1.5rem; } @keyframes spin { to { transform: rotate(360deg); } } .su-log { background: rgba(0,0,0,0.3); border-radius: 8px; padding: 1rem; max-height: 200px; overflow-y: auto; font-family: 'Space Mono', monospace; font-size: 0.8rem; } .su-log-entry { padding: 0.25rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); } .su-log-entry.success { color: #28a745; } .su-log-entry.error { color: #dc3545; } .su-log-entry.info { color: rgba(255,255,255,0.7); } .su-pagination { display: flex; justify-content: center; gap: 0.5rem; margin-top: 1.5rem; } .su-page-btn { padding: 0.5rem 1rem; border-radius: 6px; background: rgba(255,255,255,0.1); border: none; color: white; cursor: pointer; } .su-page-btn:hover:not(:disabled) { background: rgba(255,255,255,0.15); } .su-page-btn:disabled { opacity: 0.3; cursor: not-allowed; } .su-page-btn.active { background: #fc5200; } .su-whitelist-btn { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 0.25rem; opacity: 0.5; transition: opacity 0.2s; } .su-whitelist-btn:hover { opacity: 1; } .su-whitelist-btn.active { opacity: 1; } .su-settings-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000002; } .su-modal-content { background: #1a1f25; border-radius: 16px; padding: 2rem; max-width: 500px; width: 90%; } .su-modal-title { font-size: 1.5rem; font-weight: 700; color: white; margin-bottom: 1.5rem; } .su-form-group { margin-bottom: 1rem; } .su-form-label { display: block; font-size: 0.85rem; color: rgba(255,255,255,0.7); margin-bottom: 0.5rem; } .su-form-input { width: 100%; padding: 0.75rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(0,0,0,0.3); color: white; } .su-modal-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; } `; // ============================================ // STATE // ============================================ const state = { status: 'initial', // initial, scanning, results, unfollowing following: [], followers: [], nonFollowers: [], selectedAthletes: new Set(), whitelisted: new Set(JSON.parse(localStorage.getItem('strava_unfollowers_whitelist') || '[]')), currentTab: 'non_followers', searchTerm: '', page: 1, itemsPerPage: 50, progress: 0, progressText: '', unfollowLog: [], toast: null, showSettings: false, settings: { delayBetweenRequests: 1500, delayAfterFiveRequests: 8000, delayBetweenUnfollows: 3000, delayAfterFiveUnfollows: 60000, ...JSON.parse(localStorage.getItem('strava_unfollowers_settings') || '{}') }, athleteId: null }; // ============================================ // UTILITIES // ============================================ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); function saveWhitelist() { localStorage.setItem('strava_unfollowers_whitelist', JSON.stringify([...state.whitelisted])); } function saveSettings() { localStorage.setItem('strava_unfollowers_settings', JSON.stringify(state.settings)); } function getCsrfToken() { const meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.getAttribute('content') : ''; } function showToast(message, type = 'info', duration = 4000) { state.toast = { message, type }; render(); if (duration > 0) { setTimeout(() => { state.toast = null; render(); }, duration); } } // ============================================ // STRAVA API FUNCTIONS // ============================================ async function fetchFollowsPage(athleteId, type, page = 1) { const url = `https://www.strava.com/athletes/${athleteId}/follows?type=${type}&page=${page}`; try { const response = await fetch(url, { credentials: 'include', headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const html = await response.text(); return parseAthletesFromHTML(html); } catch (error) { console.error(`Error fetching ${type} page ${page}:`, error); return { athletes: [], hasMore: false }; } } function parseAthletesFromHTML(html) { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const athletes = []; // Athletes are in
  • elements const athleteElements = doc.querySelectorAll('li[data-athlete-id]'); athleteElements.forEach(el => { const athlete = parseAthleteCard(el); if (athlete) athletes.push(athlete); }); // Check for next page - look for pagination link with rel="next" const nextPageLink = doc.querySelector('.pagination a[rel="next"]'); const hasMore = nextPageLink !== null; console.log(`Parsed ${athletes.length} athletes, hasMore: ${hasMore}`); return { athletes, hasMore }; } function parseAthleteCard(element) { try { // Get athlete ID from data attribute const id = element.getAttribute('data-athlete-id'); if (!id) return null; // Get name from .text-callout a let name = ''; const nameLink = element.querySelector('.text-callout a'); if (nameLink) { name = nameLink.textContent.trim(); } // Get location from .location div let location = ''; const locationEl = element.querySelector('.location'); if (locationEl) { location = locationEl.textContent.trim(); } // Get avatar and premium status from data-react-props let avatar = 'https://d3nn82uaxijpm6.cloudfront.net/assets/avatar/athlete/large-59a8e8528934934c80cc56ea197a256eb5dc71bc6e6451ba5769cdd968c7e232.png'; let isPremium = false; const avatarWrapper = element.querySelector('[data-react-props]'); if (avatarWrapper) { try { const props = JSON.parse(avatarWrapper.getAttribute('data-react-props')); if (props.src) { avatar = props.src; } if (props.badge === 'premium') { isPremium = true; } } catch (e) { // JSON parse error, use defaults } } // Get the follow relationship ID from the unfollow button let followId = null; const unfollowBtn = element.querySelector('button[data-follow]'); if (unfollowBtn) { followId = unfollowBtn.getAttribute('data-follow'); } return { id, name, avatar, location, isPremium, followId }; } catch (error) { console.error('Error parsing athlete card:', error); return null; } } async function getAllFollows(athleteId, type, onProgress) { const allAthletes = []; let page = 1; let hasMore = true; let requestCount = 0; while (hasMore) { const result = await fetchFollowsPage(athleteId, type, page); if (result.athletes.length === 0 && page === 1) { // First page empty means no data hasMore = false; break; } allAthletes.push(...result.athletes); hasMore = result.hasMore; page++; requestCount++; if (onProgress) { onProgress(allAthletes.length, type); } // Rate limiting if (hasMore) { if (requestCount % 5 === 0) { state.progressText = `Sleeping ${state.settings.delayAfterFiveRequests / 1000}s to avoid rate limiting...`; render(); await sleep(state.settings.delayAfterFiveRequests); } else { await sleep(state.settings.delayBetweenRequests); } } } // Remove duplicates by ID const uniqueAthletes = []; const seenIds = new Set(); for (const athlete of allAthletes) { if (!seenIds.has(athlete.id)) { seenIds.add(athlete.id); uniqueAthletes.push(athlete); } } return uniqueAthletes; } async function unfollowAthlete(athlete) { const csrfToken = getCsrfToken(); const myAthleteId = state.athleteId; if (!myAthleteId) { console.error('No athlete ID found'); return false; } if (!athlete.followId) { console.error(`No followId for ${athlete.name}`); return false; } // The correct endpoint: DELETE /athletes/{MY_ID}/follows/{FOLLOW_ID} const url = `https://www.strava.com/athletes/${myAthleteId}/follows/${athlete.followId}`; console.log(`Unfollowing ${athlete.name}: DELETE ${url}`); try { const response = await fetch(url, { method: 'DELETE', credentials: 'include', headers: { 'X-CSRF-Token': csrfToken, 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript' } }); if (response.ok || response.status === 204) { console.log(`✓ Successfully unfollowed ${athlete.name}`); return true; } else { console.error(`Failed to unfollow ${athlete.name}: ${response.status}`); return false; } } catch (error) { console.error(`Error unfollowing ${athlete.name}:`, error); return false; } } // ============================================ // DETECT ATHLETE ID // ============================================ async function detectAthleteId() { // Try URL first const urlMatch = location.pathname.match(/\/athletes\/(\d+)/); if (urlMatch) { return urlMatch[1]; } // Try to find from page const links = document.querySelectorAll('a[href*="/athletes/"]'); for (const link of links) { if (link.classList.contains('nav-link') || link.closest('.user-nav, .athlete-profile')) { const match = link.href.match(/\/athletes\/(\d+)/); if (match) return match[1]; } } // Try fetching profile page try { const response = await fetch('https://www.strava.com/settings/profile', { credentials: 'include' }); const html = await response.text(); // Look for athlete ID in various places const patterns = [ /\/athletes\/(\d+)/, /"athleteId":(\d+)/, /data-athlete-id="(\d+)"/ ]; for (const pattern of patterns) { const match = html.match(pattern); if (match) return match[1]; } } catch (err) { console.error('Failed to get athlete ID from profile:', err); } return null; } // ============================================ // SCAN & UNFOLLOW OPERATIONS // ============================================ async function startScan() { state.status = 'scanning'; state.progress = 0; state.progressText = 'Detecting your athlete ID...'; state.following = []; state.followers = []; state.nonFollowers = []; render(); // Get athlete ID const athleteId = await detectAthleteId(); if (!athleteId) { showToast('Could not detect your athlete ID. Please go to your profile page first.', 'error'); state.status = 'initial'; render(); return; } state.athleteId = athleteId; console.log(`Found athlete ID: ${athleteId}`); state.progressText = `Found athlete ID: ${athleteId}. Scanning who you follow...`; render(); // Fetch following list state.following = await getAllFollows(athleteId, 'following', (count) => { state.progressText = `Found ${count} people you follow...`; state.progress = Math.min(45, Math.round((count / 200) * 45)); render(); }); console.log(`Total following: ${state.following.length}`); state.progress = 50; state.progressText = `Found ${state.following.length} following. Now scanning your followers...`; render(); // Fetch followers list state.followers = await getAllFollows(athleteId, 'followers', (count) => { state.progressText = `Found ${count} followers...`; state.progress = Math.min(95, 50 + Math.round((count / 200) * 45)); render(); }); console.log(`Total followers: ${state.followers.length}`); // Calculate non-followers const followerIds = new Set(state.followers.map(f => f.id)); state.nonFollowers = state.following.filter(a => !followerIds.has(a.id)); console.log(`Non-followers: ${state.nonFollowers.length}`); state.progress = 100; state.status = 'results'; render(); showToast(`✓ Scan complete! Found ${state.nonFollowers.length} non-followers out of ${state.following.length} following.`, 'success', 5000); } async function startUnfollow() { const selectedArray = Array.from(state.selectedAthletes); if (selectedArray.length === 0) { showToast('No athletes selected', 'warning'); return; } if (!confirm(`Are you sure you want to unfollow ${selectedArray.length} athlete(s)?`)) { return; } state.status = 'unfollowing'; state.unfollowLog = []; state.progress = 0; render(); let count = 0; for (const id of selectedArray) { const athlete = state.following.find(a => a.id === id); if (!athlete) continue; count++; state.progress = Math.round((count / selectedArray.length) * 100); state.progressText = `Unfollowing ${athlete.name}... (${count}/${selectedArray.length})`; render(); const success = await unfollowAthlete(athlete); state.unfollowLog.push({ athlete, success }); if (success) { state.following = state.following.filter(a => a.id !== id); state.nonFollowers = state.nonFollowers.filter(a => a.id !== id); } render(); // Delay between unfollows if (count < selectedArray.length) { if (count % 5 === 0) { state.progressText = `Sleeping ${state.settings.delayAfterFiveUnfollows / 1000}s to avoid rate limiting...`; render(); await sleep(state.settings.delayAfterFiveUnfollows); } else { await sleep(state.settings.delayBetweenUnfollows); } } } state.selectedAthletes = new Set(); state.status = 'results'; const successCount = state.unfollowLog.filter(l => l.success).length; showToast(`✓ Unfollowed ${successCount}/${selectedArray.length} athlete(s)`, 'success', 5000); render(); } // ============================================ // RENDER FUNCTIONS // ============================================ function getFilteredAthletes() { let athletes = []; switch (state.currentTab) { case 'non_followers': athletes = state.nonFollowers.filter(a => !state.whitelisted.has(a.id)); break; case 'whitelisted': athletes = state.nonFollowers.filter(a => state.whitelisted.has(a.id)); break; case 'all_following': athletes = state.following; break; } if (state.searchTerm) { const term = state.searchTerm.toLowerCase(); athletes = athletes.filter(a => a.name.toLowerCase().includes(term) || (a.location && a.location.toLowerCase().includes(term)) ); } return athletes; } function renderAthleteCard(athlete) { const isSelected = state.selectedAthletes.has(athlete.id); const isWhitelisted = state.whitelisted.has(athlete.id); const isFollower = state.followers.some(f => f.id === athlete.id); return `
    ${athlete.name}
    ${athlete.name}
    ${athlete.location || 'No location'}
    ${isFollower ? '✓ Follows you' : '✗ Doesn\'t follow you'}
    ${athlete.isPremium ? 'Premium' : ''}
    `; } function render() { let overlay = document.querySelector('.su-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.className = 'su-overlay'; document.body.appendChild(overlay); const style = document.createElement('style'); style.textContent = STYLES; document.head.appendChild(style); } const athletes = getFilteredAthletes(); const totalPages = Math.ceil(athletes.length / state.itemsPerPage); const startIdx = (state.page - 1) * state.itemsPerPage; const pageAthletes = athletes.slice(startIdx, startIdx + state.itemsPerPage); let content = ''; if (state.status === 'initial') { content = `
    🔍

    Strava Unfollowers

    Find out who doesn't follow you back on Strava. Click the button below to start scanning your connections.

    `; } else if (state.status === 'scanning') { content = `

    Scanning...

    ${state.progressText}

    `; } else if (state.status === 'unfollowing') { content = `

    Unfollowing...

    ${state.progressText}

    ${state.unfollowLog.slice(-10).map(log => `
    ${log.success ? '✓' : '✗'} ${log.athlete.name}
    `).join('')}
    `; } else { content = `
    ${state.following.length}
    Following
    ${state.followers.length}
    Followers
    ${state.nonFollowers.length}
    Non-followers
    ${state.whitelisted.size}
    Whitelisted
    Search
    Bulk Actions
    ${state.selectedAthletes.size > 0 ? `
    ` : ''}
    ${pageAthletes.length === 0 ? `
    🎉

    No athletes found

    ${state.currentTab === 'non_followers' ? 'Everyone you follow, follows you back!' : 'No matching athletes'}

    ` : `
    ${pageAthletes.map(renderAthleteCard).join('')}
    ${totalPages > 1 ? `
    Page ${state.page} of ${totalPages}
    ` : ''} `}
    `; } overlay.innerHTML = `
    ${content}
    ${state.toast ? `
    ${state.toast.message}
    ` : ''} ${state.showSettings ? renderSettingsModal() : ''} `; // Add event listeners overlay.querySelectorAll('.su-card').forEach(card => { card.addEventListener('click', (e) => { if (e.target.closest('.su-whitelist-btn')) return; const id = card.dataset.id; toggleSelection(id); }); }); overlay.querySelectorAll('.su-whitelist-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const id = btn.dataset.whitelist; toggleWhitelist(id); }); }); } function renderSettingsModal() { return `

    ⚙️ Settings

    ⚠️ Lower delays may trigger rate limiting from Strava. Use with caution.

    `; } // ============================================ // EVENT HANDLERS // ============================================ function toggleSelection(id) { if (state.selectedAthletes.has(id)) { state.selectedAthletes.delete(id); } else { state.selectedAthletes.add(id); } render(); } function toggleWhitelist(id) { if (state.whitelisted.has(id)) { state.whitelisted.delete(id); } else { state.whitelisted.add(id); state.selectedAthletes.delete(id); } saveWhitelist(); render(); } function selectAll() { const athletes = getFilteredAthletes(); athletes.forEach(a => { if (!state.whitelisted.has(a.id)) { state.selectedAthletes.add(a.id); } }); render(); } function selectPage() { const athletes = getFilteredAthletes(); const startIdx = (state.page - 1) * state.itemsPerPage; const pageAthletes = athletes.slice(startIdx, startIdx + state.itemsPerPage); pageAthletes.forEach(a => { if (!state.whitelisted.has(a.id)) { state.selectedAthletes.add(a.id); } }); render(); } function deselectAll() { state.selectedAthletes.clear(); render(); } function setTab(tab) { state.currentTab = tab; state.page = 1; state.selectedAthletes.clear(); render(); } function setSearch(term) { state.searchTerm = term; state.page = 1; render(); } function setPage(page) { state.page = page; render(); } function openSettings() { state.showSettings = true; render(); } function closeSettings() { state.showSettings = false; render(); } function updateSetting(key, value) { state.settings[key] = parseInt(value, 10); saveSettings(); } function close() { const overlay = document.querySelector('.su-overlay'); if (overlay) overlay.remove(); } // ============================================ // PUBLIC API // ============================================ window.stravaUnfollowers = { startScan, startUnfollow, selectAll, selectPage, deselectAll, setTab, setSearch, setPage, openSettings, closeSettings, updateSetting, close }; // Initialize render(); console.log('🚀 Strava Unfollowers loaded! The UI should now be visible.'); })();