您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Track your follower growth over time on FetLife with modern Chart.js visualization
// ==UserScript== // @name FetLife Follower Growth Tracker // @namespace http://violentmonkey.com/ // @version 1.0 // @description Track your follower growth over time on FetLife with modern Chart.js visualization // @match https://fetlife.com/* // @exclude https://fetlife.com/home* // @exclude https://fetlife.com/feed* // @exclude https://fetlife.com/explore* // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; console.log('🚀 FetLife Follower Tracker v2.0 with Chart.js starting...'); // Storage key for follower data const STORAGE_KEY = 'fetlifeFollowerData'; // Get current follower count from the page function getCurrentFollowerCount() { // Method 1: Try to find follower count in the page data try { // Look for the followers link in the DOM const followerLink = document.querySelector('a[href*="/followers"]'); if (followerLink) { const text = followerLink.textContent.trim(); // Look for numbers in the link text, handle comma-separated numbers const match = text.match(/([\d,]+)/); if (match) { return parseInt(match[1].replace(/,/g, ''), 10); } } } catch (e) { console.log('Method 1 failed:', e); } // Method 2: Look for any element containing "followers" text with numbers const elements = document.querySelectorAll('*'); for (const el of elements) { const text = el.textContent.toLowerCase(); if (text.includes('followers') && !text.includes('following')) { const match = el.textContent.match(/([\d,]+)/); if (match) { return parseInt(match[1].replace(/,/g, ''), 10); } } } // Method 3: Look in all links that might contain follower info const allLinks = document.querySelectorAll('a'); for (const link of allLinks) { if (link.href.includes('/followers')) { const text = link.textContent.trim(); const match = text.match(/([\d,]+)/); if (match) { return parseInt(match[1].replace(/,/g, ''), 10); } } } return null; } // Get stored follower data function getStoredData() { const data = localStorage.getItem(STORAGE_KEY); return data ? JSON.parse(data) : []; } // Save follower data point with automatic tracking function saveDataPoint(count) { const data = getStoredData(); const now = new Date(); const today = now.toISOString().split('T')[0]; // YYYY-MM-DD format const timeKey = now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0'); // HH:MM format // Check if we already have data for this exact hour today const existingIndex = data.findIndex(point => point.date === today && point.time === timeKey ); if (existingIndex >= 0) { // Update this hour's count if different if (data[existingIndex].count !== count) { data[existingIndex].count = count; data[existingIndex].timestamp = now.toISOString(); console.log(`📊 Updated follower count for ${today} ${timeKey}: ${count}`); } } else { // Add new data point const newPoint = { date: today, time: timeKey, count: count, timestamp: now.toISOString() }; data.push(newPoint); console.log(`📈 New follower count recorded for ${today} ${timeKey}: ${count}`); } localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); return data; } // Check if we're on our own profile (for auto-tracking) function isOnOwnProfile() { // Check if this is our own profile by looking at the current user data try { if (window.FL && window.FL.user && window.FL.user.nickname) { const currentUser = window.FL.user.nickname; const pathname = window.location.pathname; // Check if pathname matches our username return pathname === `/${currentUser}` || pathname.startsWith(`/${currentUser}/`); } } catch (e) { console.log('Could not determine if on own profile:', e); } return false; } // Auto-track follower count when on own profile function autoTrackFollowers() { if (!isOnOwnProfile()) { console.log('Not on own profile, skipping auto-track'); return; } // Wait a bit for page to load completely setTimeout(() => { const count = getCurrentFollowerCount(); if (count !== null) { const data = saveDataPoint(count); const latestEntry = data[data.length - 1]; console.log(`🎯 Auto-tracked followers: ${count} at ${latestEntry.time}`); // Update button text to show latest count updateChartButtonText(count); } else { console.log('Could not auto-track: follower count not found'); } }, 2000); } // Calculate check frequency and growth per check statistics function calculateCheckFrequencyStats(data) { if (data.length < 2) { return { averageTimeBetweenChecks: 0, totalChecks: data.length, growthPerCheck: 0, checkingDays: 0, averageChecksPerDay: 0 }; } // Calculate time differences between checks const timeDiffs = []; for (let i = 1; i < data.length; i++) { const prevTime = new Date(data[i - 1].timestamp); const currentTime = new Date(data[i].timestamp); const diffHours = (currentTime - prevTime) / (1000 * 60 * 60); timeDiffs.push(diffHours); } const averageTimeBetweenChecks = timeDiffs.reduce((sum, diff) => sum + diff, 0) / timeDiffs.length; // Calculate growth per check const totalGrowth = data[data.length - 1].count - data[0].count; const growthPerCheck = totalGrowth / (data.length - 1); // Calculate tracking period and frequency const firstDate = new Date(data[0].timestamp); const lastDate = new Date(data[data.length - 1].timestamp); const totalDays = Math.max(1, Math.ceil((lastDate - firstDate) / (1000 * 60 * 60 * 24))); const averageChecksPerDay = data.length / totalDays; return { averageTimeBetweenChecks, totalChecks: data.length, growthPerCheck, checkingDays: totalDays, averageChecksPerDay }; } function calculateDailyGrowth(data) { if (data.length < 2) { return { dailyAverage: 0, totalDays: 0, bestDay: { growth: 0, date: 'N/A' }, hasEnoughData: false }; } // Group data by date to calculate daily changes const dailyGrowth = {}; const dailyCounts = {}; // Group data points by date and get the last (highest) count for each day data.forEach(point => { const date = point.date; if (!dailyCounts[date]) { dailyCounts[date] = []; } dailyCounts[date].push(point); }); // Calculate growth for each day (comparing end of day to end of previous day) const dates = Object.keys(dailyCounts).sort(); let bestDayGrowth = 0; let bestDayDate = 'N/A'; const validDailyGrowths = []; for (let i = 1; i < dates.length; i++) { const prevDate = dates[i - 1]; const currentDate = dates[i]; // Get the last (latest) count of each day const prevDayLastCount = Math.max(...dailyCounts[prevDate].map(p => p.count)); const currentDayLastCount = Math.max(...dailyCounts[currentDate].map(p => p.count)); const dayGrowth = currentDayLastCount - prevDayLastCount; dailyGrowth[currentDate] = dayGrowth; validDailyGrowths.push(dayGrowth); if (dayGrowth > bestDayGrowth) { bestDayGrowth = dayGrowth; bestDayDate = new Date(currentDate).toLocaleDateString(); } } // Calculate average daily growth from actual day-to-day changes let dailyAverage = 0; if (validDailyGrowths.length > 0) { const totalDailyGrowth = validDailyGrowths.reduce((sum, growth) => sum + growth, 0); dailyAverage = totalDailyGrowth / validDailyGrowths.length; } else if (dates.length >= 2) { // Fallback: if we have data spanning multiple days but not enough daily comparisons const firstDate = new Date(data[0].timestamp); const lastDate = new Date(data[data.length - 1].timestamp); const actualDays = Math.max(1, Math.ceil((lastDate - firstDate) / (1000 * 60 * 60 * 24))); // Only calculate if we have at least 2 full days of data if (actualDays >= 2) { const totalGrowth = data[data.length - 1].count - data[0].count; dailyAverage = totalGrowth / actualDays; } } return { dailyAverage, totalDays: Math.max(dates.length - 1, 1), bestDay: { growth: bestDayGrowth, date: bestDayDate }, hasEnoughData: validDailyGrowths.length > 0 || dates.length >= 2 }; } // Get milestone emoji based on follower count function getMilestoneEmoji(milestone) { if (milestone >= 100000) return "👑"; if (milestone >= 50000) return "🌟"; if (milestone >= 25000) return "🚀"; if (milestone >= 10000) return "💎"; if (milestone >= 7500) return "🔥"; if (milestone >= 5000) return "⭐"; if (milestone >= 3000) return "🎯"; if (milestone >= 2000) return "🏆"; if (milestone >= 1500) return "🎉"; if (milestone >= 1000) return "🥳"; if (milestone >= 500) return "🎊"; return "🌸"; } // Create D3.js chart with zoom functionality function createD3Chart(container, data) { // Clear container d3.select(container).selectAll("*").remove(); // Set dimensions and margins const margin = { top: 20, right: 30, bottom: 60, left: 80 }; const width = container.clientWidth - margin.left - margin.right; const height = container.clientHeight - margin.top - margin.bottom; // Create SVG const svg = d3.select(container) .append("svg") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom); const g = svg.append("g") .attr("transform", `translate(${margin.left},${margin.top})`); // Create clip path for zooming svg.append("defs").append("clipPath") .attr("id", "clip") .append("rect") .attr("width", width) .attr("height", height); // Create scales let xScale, yScale, originalXScale, originalYScale; if (data.length === 0) { // Empty state - show default axes const now = new Date(); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); xScale = d3.scaleTime() .domain([oneHourAgo, now]) .range([0, width]); yScale = d3.scaleLinear() .domain([0, 1000]) .range([height, 0]); originalXScale = xScale.copy(); originalYScale = yScale.copy(); } else { // With data const parseTime = d3.timeParse("%Y-%m-%dT%H:%M:%S.%LZ"); const processedData = data.map(d => ({ date: new Date(d.timestamp), count: d.count })); const counts = processedData.map(d => d.count); const minCount = d3.min(counts); const maxCount = d3.max(counts); const range = maxCount - minCount; const padding = Math.max(range * 0.1, 10); xScale = d3.scaleTime() .domain(d3.extent(processedData, d => d.date)) .range([0, width]); yScale = d3.scaleLinear() .domain([Math.max(0, minCount - padding), maxCount + padding]) .range([height, 0]); // Store original scales for reset originalXScale = xScale.copy(); originalYScale = yScale.copy(); } // Create zoom behavior const zoom = d3.zoom() .scaleExtent([1, 50]) .extent([[0, 0], [width, height]]) .on("zoom", zoomed); // Apply zoom to SVG svg.call(zoom); // Create containers for zoomable content const gridContainer = g.append("g").attr("class", "grid-container"); const dataContainer = g.append("g").attr("class", "data-container").attr("clip-path", "url(#clip)"); const axisContainer = g.append("g").attr("class", "axis-container"); function updateChart() { // Clear previous content gridContainer.selectAll("*").remove(); dataContainer.selectAll("*").remove(); axisContainer.selectAll("*").remove(); // Create grid lines // X-axis grid gridContainer.selectAll(".grid-x") .data(xScale.ticks()) .enter() .append("line") .attr("class", "grid-x") .attr("x1", d => xScale(d)) .attr("x2", d => xScale(d)) .attr("y1", 0) .attr("y2", height) .attr("stroke", "rgba(255, 255, 255, 0.1)") .attr("stroke-width", 1); // Y-axis grid gridContainer.selectAll(".grid-y") .data(yScale.ticks()) .enter() .append("line") .attr("class", "grid-y") .attr("x1", 0) .attr("x2", width) .attr("y1", d => yScale(d)) .attr("y2", d => yScale(d)) .attr("stroke", "rgba(255, 255, 255, 0.1)") .attr("stroke-width", 1); // Create axes const xAxis = d3.axisBottom(xScale) .tickFormat(d3.timeFormat("%m/%d %H:%M")); const yAxis = d3.axisLeft(yScale) .tickFormat(d => d.toLocaleString()); // Add X axis axisContainer.append("g") .attr("class", "x-axis") .attr("transform", `translate(0,${height})`) .call(xAxis) .selectAll("text") .style("fill", "#999") .style("font-family", "monospace") .style("font-size", "11px"); // Add Y axis axisContainer.append("g") .attr("class", "y-axis") .call(yAxis) .selectAll("text") .style("fill", "#999") .style("font-family", "monospace") .style("font-size", "11px"); // Style axis lines axisContainer.selectAll(".domain") .style("stroke", "#404040"); axisContainer.selectAll(".tick line") .style("stroke", "#404040"); if (data.length === 0) { // Add empty state message dataContainer.append("text") .attr("x", width / 2) .attr("y", height / 2 - 10) .attr("text-anchor", "middle") .style("fill", "#666") .style("font-size", "16px") .text("📊"); dataContainer.append("text") .attr("x", width / 2) .attr("y", height / 2 + 10) .attr("text-anchor", "middle") .style("fill", "#666") .style("font-size", "14px") .text("Start tracking to see your growth!"); } else { // Process data for line const processedData = data.map(d => ({ date: new Date(d.timestamp), count: d.count })); // Create line generator const line = d3.line() .x(d => xScale(d.date)) .y(d => yScale(d.count)) .curve(d3.curveCardinal); // Add area under the line const area = d3.area() .x(d => xScale(d.date)) .y0(height) .y1(d => yScale(d.count)) .curve(d3.curveCardinal); // Add the area dataContainer.append("path") .datum(processedData) .attr("fill", "rgba(229, 62, 62, 0.1)") .attr("d", area); // Add the line dataContainer.append("path") .datum(processedData) .attr("fill", "none") .attr("stroke", "#e53e3e") .attr("stroke-width", 3) .attr("d", line); // Add milestone markers const milestones = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 7500, 10000, 15000, 20000, 25000, 50000, 100000]; const achievedMilestones = []; // Debug: log the data we're working with console.log('Processing milestones for data:', processedData.map(d => d.count)); // Find achieved milestones with correct logic milestones.forEach(milestone => { // Find the first data point that crosses this milestone let milestonePoint = null; let wasCrossed = false; // Look for the crossing point for (let i = 0; i < processedData.length; i++) { const currentCount = processedData[i].count; const prevCount = i > 0 ? processedData[i - 1].count : 0; // Check if this point crosses the milestone if (currentCount >= milestone && prevCount < milestone) { milestonePoint = processedData[i]; wasCrossed = true; console.log(`Milestone ${milestone} crossed at point:`, milestonePoint.count); break; } } if (wasCrossed && milestonePoint) { // Double-check: make sure this milestone hasn't been added already const alreadyExists = achievedMilestones.some(existing => existing.milestone === milestone); if (!alreadyExists) { achievedMilestones.push({ milestone: milestone, point: milestonePoint, emoji: getMilestoneEmoji(milestone) }); console.log(`Added milestone: ${milestone} at count ${milestonePoint.count}`); } } }); console.log('Final achieved milestones:', achievedMilestones.map(m => m.milestone)); // Check if milestones should be shown const showMilestones = container.parentElement.querySelector('.milestone-toggle')?.checked !== false; // Add milestone markers (only if enabled) if (showMilestones) { console.log('Drawing milestones:', achievedMilestones); achievedMilestones.forEach((ms, index) => { const x = xScale(ms.point.date); const y = yScale(ms.point.count); console.log(`Drawing milestone ${ms.milestone} at position (${x}, ${y})`); // Add subtle milestone line (vertical dashed line) dataContainer.append("line") .attr("class", "milestone-line") .attr("data-milestone", ms.milestone) .attr("x1", x) .attr("x2", x) .attr("y1", 0) .attr("y2", height) .attr("stroke", "#ff6b6b") .attr("stroke-width", 1) .attr("stroke-dasharray", "3,3") .attr("opacity", 0.4); // Add milestone marker (subtle special dot) dataContainer.append("circle") .attr("class", "milestone-marker") .attr("data-milestone", ms.milestone) .attr("data-index", index) .attr("cx", x) .attr("cy", y) .attr("r", 8) .attr("fill", "#e53e3e") .attr("stroke", "#ff6b6b") .attr("stroke-width", 2) .style("cursor", "pointer") .style("filter", "drop-shadow(0px 0px 3px rgba(229, 62, 62, 0.6))"); // Add milestone emoji (smaller and more subtle) dataContainer.append("text") .attr("class", "milestone-emoji") .attr("data-milestone", ms.milestone) .attr("x", x) .attr("y", y + 3) .attr("text-anchor", "middle") .attr("font-size", "10px") .text(ms.emoji) .style("pointer-events", "none"); }); // Add milestone tooltips dataContainer.selectAll(".milestone-marker") .on("mouseover", function(event, d) { // Get milestone data from the element's data attribute const milestoneValue = parseInt(this.getAttribute('data-milestone')); const milestoneIndex = parseInt(this.getAttribute('data-index')); const milestone = achievedMilestones[milestoneIndex]; console.log('Hovering milestone:', milestoneValue, milestone); if (milestone && milestone.milestone === milestoneValue) { const containerRect = container.getBoundingClientRect(); const mouseX = event.clientX - containerRect.left; const mouseY = event.clientY - containerRect.top; // Remove existing milestone tooltip d3.select(container).selectAll(".milestone-tooltip").remove(); const milestoneTooltip = d3.select(container) .append("div") .attr("class", "milestone-tooltip") .style("position", "absolute") .style("padding", "8px 12px") .style("background", "linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)") .style("color", "#ff6b6b") .style("border", "1px solid #e53e3e") .style("border-radius", "8px") .style("font-size", "12px") .style("font-weight", "600") .style("font-family", "monospace") .style("pointer-events", "none") .style("opacity", 0) .style("z-index", "1001") .style("box-shadow", "0 4px 12px rgba(229, 62, 62, 0.3)") .html(`🎯 <strong>Milestone Reached!</strong><br/>${milestone.emoji} ${milestone.milestone.toLocaleString()} followers<br/><small style="color: #999;">${milestone.point.date.toLocaleDateString()} ${milestone.point.date.toLocaleTimeString()}</small>`); milestoneTooltip.transition() .duration(200) .style("opacity", 1); milestoneTooltip .style("left", (mouseX + 15) + "px") .style("top", (mouseY - 15) + "px"); } }) .on("mouseout", function() { d3.select(container).selectAll(".milestone-tooltip") .transition() .duration(300) .style("opacity", 0) .remove(); }); } // Add dots dataContainer.selectAll(".dot") .data(processedData) .enter().append("circle") .attr("class", "dot") .attr("cx", d => xScale(d.date)) .attr("cy", d => yScale(d.count)) .attr("r", 6) .attr("fill", "#ff6b6b") .attr("stroke", "#e53e3e") .attr("stroke-width", 2) .style("cursor", "pointer"); // Remove existing tooltip to avoid duplicates d3.select(container).selectAll(".tooltip").remove(); // Add tooltips const tooltip = d3.select(container) .append("div") .attr("class", "tooltip") .style("position", "absolute") .style("padding", "8px 12px") .style("background", "rgba(26, 26, 26, 0.95)") .style("color", "#ff6b6b") .style("border", "1px solid #404040") .style("border-radius", "8px") .style("font-size", "12px") .style("font-family", "monospace") .style("pointer-events", "none") .style("opacity", 0) .style("z-index", "1000"); dataContainer.selectAll(".dot") .on("mouseover", function(event, d) { // Get the container's position for proper tooltip positioning const containerRect = container.getBoundingClientRect(); const mouseX = event.clientX - containerRect.left; const mouseY = event.clientY - containerRect.top; tooltip.transition() .duration(200) .style("opacity", 1); tooltip.html(`${d.date.toLocaleDateString()} ${d.date.toLocaleTimeString()}<br/>${d.count.toLocaleString()} followers`) .style("left", (mouseX + 10) + "px") .style("top", (mouseY - 10) + "px"); }) .on("mouseout", function(d) { tooltip.transition() .duration(500) .style("opacity", 0); }) .on("mousemove", function(event, d) { // Update tooltip position as mouse moves const containerRect = container.getBoundingClientRect(); const mouseX = event.clientX - containerRect.left; const mouseY = event.clientY - containerRect.top; tooltip .style("left", (mouseX + 10) + "px") .style("top", (mouseY - 10) + "px"); }); } } function zoomed(event) { const transform = event.transform; // Update scales with zoom transform const newXScale = transform.rescaleX(originalXScale); const newYScale = transform.rescaleY(originalYScale); xScale = newXScale; yScale = newYScale; // Update chart updateChart(); } // Add axis labels g.append("text") .attr("transform", "rotate(-90)") .attr("y", 0 - margin.left) .attr("x", 0 - (height / 2)) .attr("dy", "1em") .style("text-anchor", "middle") .style("fill", "#999") .style("font-size", "12px") .text("Followers"); g.append("text") .attr("transform", `translate(${width / 2}, ${height + margin.bottom - 10})`) .style("text-anchor", "middle") .style("fill", "#999") .style("font-size", "12px") .text("Time"); // Add zoom controls const zoomControls = d3.select(container) .append("div") .style("position", "absolute") .style("top", "10px") .style("right", "10px") .style("display", "flex") .style("gap", "4px"); // Zoom in button zoomControls.append("button") .style("padding", "4px 8px") .style("background", "rgba(26, 26, 26, 0.9)") .style("color", "#e53e3e") .style("border", "1px solid #404040") .style("border-radius", "4px") .style("cursor", "pointer") .style("font-size", "12px") .text("🔍+") .on("click", function() { svg.transition().duration(300).call(zoom.scaleBy, 1.5); }); // Zoom out button zoomControls.append("button") .style("padding", "4px 8px") .style("background", "rgba(26, 26, 26, 0.9)") .style("color", "#e53e3e") .style("border", "1px solid #404040") .style("border-radius", "4px") .style("cursor", "pointer") .style("font-size", "12px") .text("🔍-") .on("click", function() { svg.transition().duration(300).call(zoom.scaleBy, 0.67); }); // Reset zoom button zoomControls.append("button") .style("padding", "4px 8px") .style("background", "rgba(26, 26, 26, 0.9)") .style("color", "#e53e3e") .style("border", "1px solid #404040") .style("border-radius", "4px") .style("cursor", "pointer") .style("font-size", "12px") .text("↺") .on("click", function() { svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity); }); // Initial chart render updateChart(); // Add zoom instructions if (data.length > 0) { const instructions = d3.select(container) .append("div") .style("position", "absolute") .style("bottom", "10px") .style("left", "10px") .style("background", "rgba(26, 26, 26, 0.8)") .style("color", "#999") .style("padding", "4px 8px") .style("border-radius", "4px") .style("font-size", "10px") .style("font-family", "monospace") .text("🖱️ Scroll to zoom • Drag to pan • Click buttons to control"); } } function showChart(data) { // Create modal overlay const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 1000000; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(10px); `; // Create modal content with dark theme const modal = document.createElement('div'); modal.style.cssText = ` background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); padding: 24px; border-radius: 16px; max-width: 1100px; width: 95%; max-height: 90vh; position: relative; border: 1px solid #404040; box-shadow: 0 20px 60px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1); color: #e53e3e; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow-y: auto; `; // Close button with FetLife styling const closeBtn = document.createElement('button'); closeBtn.textContent = '×'; closeBtn.style.cssText = ` position: absolute; top: 16px; right: 20px; background: rgba(229, 62, 62, 0.15); border: 1px solid rgba(229, 62, 62, 0.3); border-radius: 8px; padding: 8px 12px; font-size: 20px; cursor: pointer; color: #e53e3e; transition: all 0.2s ease; backdrop-filter: blur(5px); `; closeBtn.onmouseover = () => { closeBtn.style.background = 'rgba(229, 62, 62, 0.25)'; closeBtn.style.color = '#ff6b6b'; }; closeBtn.onmouseout = () => { closeBtn.style.background = 'rgba(229, 62, 62, 0.15)'; closeBtn.style.color = '#e53e3e'; }; closeBtn.onclick = () => document.body.removeChild(overlay); // Chart title const title = document.createElement('h2'); title.textContent = '📈 Follower Growth Analytics'; title.style.cssText = ` margin: 0 0 16px 0; color: #e53e3e; text-align: center; font-size: 24px; font-weight: 600; text-shadow: 0 2px 4px rgba(0,0,0,0.5); letter-spacing: 0.5px; `; // Time range controls const timeRangeContainer = document.createElement('div'); timeRangeContainer.style.cssText = ` display: flex; justify-content: center; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; `; const timeRanges = [ { label: '4H', hours: 4, active: false }, { label: '8H', hours: 8, active: false }, { label: '12H', hours: 12, active: false }, { label: '24H', hours: 24, active: false }, { label: '2D', hours: 24 * 2, active: false }, { label: '3D', hours: 24 * 3, active: false }, { label: '5D', hours: 24 * 5, active: false }, { label: '7D', hours: 24 * 7, active: false }, { label: '14D', hours: 24 * 14, active: false }, { label: '30D', hours: 24 * 30, active: false }, { label: 'ALL', hours: null, active: true } ]; let currentTimeRange = 'ALL'; let currentData = data; timeRanges.forEach(range => { const btn = document.createElement('button'); btn.textContent = range.label; btn.style.cssText = ` padding: 8px 16px; border: 1px solid #404040; border-radius: 6px; background: ${range.active ? 'linear-gradient(135deg, #e53e3e 0%, #ff6b6b 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)'}; color: ${range.active ? '#fff' : '#e53e3e'}; cursor: pointer; font-weight: 600; font-size: 12px; font-family: inherit; transition: all 0.2s ease; min-width: 50px; `; btn.onmouseover = () => { if (currentTimeRange !== range.label) { btn.style.background = 'linear-gradient(135deg, #2d2d2d 0%, #3a3a3a 100%)'; btn.style.color = '#ff6b6b'; } }; btn.onmouseout = () => { if (currentTimeRange !== range.label) { btn.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)'; btn.style.color = '#e53e3e'; } }; btn.onclick = () => { // Update active state currentTimeRange = range.label; // Filter data based on time range if (range.hours) { const cutoffTime = new Date(Date.now() - range.hours * 60 * 60 * 1000); currentData = data.filter(point => new Date(point.timestamp) >= cutoffTime); } else { currentData = data; // Show all data } // Update all buttons timeRangeContainer.querySelectorAll('button').forEach(b => { if (b === btn) { b.style.background = 'linear-gradient(135deg, #e53e3e 0%, #ff6b6b 100%)'; b.style.color = '#fff'; } else { b.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)'; b.style.color = '#e53e3e'; } }); // Recreate chart with filtered data const chartDiv = document.getElementById('followerChart'); if (chartDiv && window.d3) { createD3Chart(chartDiv, currentData); } // Update stats with filtered data updateStatsForTimeRange(currentData); }; timeRangeContainer.appendChild(btn); }); // Chart container const chartContainer = document.createElement('div'); chartContainer.style.cssText = ` width: 100%; height: 500px; position: relative; border: 1px solid #404040; background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%); margin-bottom: 20px; border-radius: 12px; padding: 20px; box-sizing: border-box; `; if (data.length === 0) { // Create D3.js chart with empty state const chartDiv = document.createElement('div'); chartDiv.id = 'followerChart'; chartDiv.style.cssText = 'width: 100%; height: 100%; position: relative;'; chartContainer.appendChild(chartDiv); // Load D3.js and create empty chart const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js'; script.onload = () => { createD3Chart(chartDiv, []); }; document.head.appendChild(script); } else { // Create D3.js chart with data const chartDiv = document.createElement('div'); chartDiv.id = 'followerChart'; chartDiv.style.cssText = 'width: 100%; height: 100%; position: relative;'; chartContainer.appendChild(chartDiv); // Load D3.js and create chart const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js'; script.onload = () => { createD3Chart(chartDiv, data); }; document.head.appendChild(script); } // Calculate daily growth statistics const dailyStats = calculateDailyGrowth(currentData); // Calculate check frequency statistics const checkStats = calculateCheckFrequencyStats(currentData); // Function to update stats for time range function updateStatsForTimeRange(filteredData) { const newDailyStats = calculateDailyGrowth(filteredData); const newCheckStats = calculateCheckFrequencyStats(filteredData); const newCurrentCount = filteredData.length > 0 ? filteredData[filteredData.length - 1].count : 0; // Calculate intelligent recent change for filtered data let newRecentChange = 0; let newChangeLabel = 'Recent Change'; let newShowRecentChange = false; if (filteredData.length >= 2) { const now = new Date(); const latest = filteredData[filteredData.length - 1]; // For different time ranges, use different comparison logic let comparisonPoint = null; if (currentTimeRange === '4H') { // For 4H view, compare to 2h ago const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= twoHoursAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 2h'; break; } } } else if (currentTimeRange === '8H') { // For 8H view, compare to 4h ago const fourHoursAgo = new Date(now.getTime() - 4 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= fourHoursAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 4h'; break; } } } else if (currentTimeRange === '12H') { // For 12H view, compare to 6h ago const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= sixHoursAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 6h'; break; } } } else if (currentTimeRange === '24H') { // For 24H view, compare to 12h ago or earlier const twelveHoursAgo = new Date(now.getTime() - 12 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= twelveHoursAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 12h'; break; } } } else if (currentTimeRange === '2D') { // For 2D view, compare to 1 day ago const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= oneDayAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 24h'; break; } } } else if (currentTimeRange === '3D') { // For 3D view, compare to 1.5 days ago const oneAndHalfDaysAgo = new Date(now.getTime() - 1.5 * 24 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= oneAndHalfDaysAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 1.5d'; break; } } } else if (currentTimeRange === '5D') { // For 5D view, compare to 2 days ago const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= twoDaysAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 2d'; break; } } } else if (currentTimeRange === '14D') { // For 14D view, compare to 7 days ago const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= sevenDaysAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 7d'; break; } } } else if (currentTimeRange === '7D') { // For 7D view, compare to 1 day ago const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= oneDayAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 24h'; break; } } } else if (currentTimeRange === '30D') { // For 30D view, compare to 7 days ago const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= sevenDaysAgo) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 7d'; break; } } } else { // For ALL view, use the original logic const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); for (let i = filteredData.length - 2; i >= 0; i--) { const pointDate = new Date(filteredData[i].timestamp); if (pointDate <= yesterday) { comparisonPoint = filteredData[i]; newChangeLabel = 'Last 24h'; break; } } } if (comparisonPoint) { newRecentChange = newCurrentCount - comparisonPoint.count; newShowRecentChange = true; } } // Calculate total growth for filtered data (smart logic) let newTotalGrowth = 0; let newShowTotalGrowth = false; let newFirstDate = ''; if (filteredData.length >= 3) { // Need at least 3 points to show meaningful growth // Only show period growth if it's been more than a few hours of tracking const timeSinceStart = new Date().getTime() - new Date(filteredData[0].timestamp).getTime(); const hoursSinceStart = timeSinceStart / (1000 * 60 * 60); if (hoursSinceStart >= 6) { // Only after 6+ hours of tracking newTotalGrowth = newCurrentCount - filteredData[0].count; newFirstDate = new Date(filteredData[0].timestamp).toLocaleDateString(); // For ALL view, only show if growth is reasonable (not the initial jump) if (currentTimeRange === 'ALL') { // Only show total growth if we have multiple data points over time // and the growth seems realistic (not just the initial data point) if (filteredData.length >= 5 && Math.abs(newTotalGrowth) <= 500) { newShowTotalGrowth = true; } } else { // For time-filtered views, always show if we have data newShowTotalGrowth = true; } } } // Check if realistic metrics should be shown const showRealisticMetrics = container.parentElement.querySelector('.realistic-toggle')?.checked !== false; // Update the stats display stats.innerHTML = ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Current Followers</strong><br> <span style="font-size: 28px; color: #e53e3e; font-weight: 600;">${newCurrentCount.toLocaleString()}</span> </div> ${newShowRecentChange ? ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">${newChangeLabel}</strong><br> <span style="font-size: 28px; color: ${newRecentChange >= 0 ? '#4ade80' : '#ef4444'}; font-weight: 600;"> ${newRecentChange >= 0 ? '+' : ''}${newRecentChange} </span> </div> ` : ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Recent Change</strong><br> <span style="font-size: 20px; color: #666; font-weight: 600;"> ${currentTimeRange === 'ALL' ? 'Just started tracking' : 'No data in range'} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Check back later</div> </div> `} <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Growth per Check</strong><br> ${newCheckStats.totalChecks >= 2 ? ` <span style="font-size: 24px; color: ${newCheckStats.growthPerCheck >= 0 ? '#4ade80' : '#ef4444'}; font-weight: 600;"> ${newCheckStats.growthPerCheck >= 0 ? '+' : ''}${newCheckStats.growthPerCheck.toFixed(1)} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Per profile visit</div> ` : ` <span style="font-size: 20px; color: #666; font-weight: 600;"> Need more checks </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Visit more often</div> `} </div> ${showRealisticMetrics ? ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Daily Average</strong><br> ${newDailyStats.hasEnoughData ? ` <span style="font-size: 24px; color: ${newDailyStats.dailyAverage >= 0 ? '#4ade80' : '#ef4444'}; font-weight: 600;"> ${newDailyStats.dailyAverage >= 0 ? '+' : ''}${newDailyStats.dailyAverage.toFixed(1)}/day </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Over ${newDailyStats.totalDays} days</div> ` : ` <span style="font-size: 20px; color: #666; font-weight: 600;"> Calculating... </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Need more time</div> `} </div> ` : ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Check Frequency</strong><br> ${newCheckStats.totalChecks >= 2 ? ` <span style="font-size: 18px; color: #e53e3e; font-weight: 600;"> ${newCheckStats.averageTimeBetweenChecks < 24 ? `${newCheckStats.averageTimeBetweenChecks.toFixed(1)}h` : `${(newCheckStats.averageTimeBetweenChecks / 24).toFixed(1)}d`} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Between visits</div> ` : ` <span style="font-size: 20px; color: #666; font-weight: 600;"> Just started </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Keep tracking</div> `} </div> `} <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Total Checks</strong><br> <span style="font-size: 24px; color: #e53e3e; font-weight: 600;">${newCheckStats.totalChecks}</span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Profile visits</div> </div> ${newShowTotalGrowth ? ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Period Growth</strong><br> <span style="font-size: 20px; color: ${newTotalGrowth >= 0 ? '#4ade80' : '#ef4444'}; font-weight: 600;"> ${newTotalGrowth >= 0 ? '+' : ''}${newTotalGrowth} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">In ${currentTimeRange === 'ALL' ? 'tracking period' : currentTimeRange}</div> </div> ` : ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Tracking Since</strong><br> <span style="font-size: 16px; color: #e53e3e; font-weight: 600;"> ${data.length > 0 ? new Date(data[0].timestamp).toLocaleDateString() : 'Today'} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">${filteredData.length} data points</div> </div> `} `; } // Stats section with enhanced metrics const stats = document.createElement('div'); stats.style.cssText = ` display: flex; justify-content: space-around; text-align: center; margin-bottom: 20px; background: rgba(15, 15, 15, 0.6); padding: 16px; border-radius: 12px; border: 1px solid #2d2d2d; flex-wrap: wrap; gap: 16px; `; const currentCount = data.length > 0 ? data[data.length - 1].count : 0; // Calculate intelligent recent change let recentChange = 0; let changeLabel = 'Recent Change'; let showRecentChange = false; if (data.length >= 2) { const now = new Date(); const latest = data[data.length - 1]; // Look for a meaningful comparison point (not the very first data point) let comparisonPoint = null; // Try to find data from 24 hours ago const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); for (let i = data.length - 2; i >= 0; i--) { const pointDate = new Date(data[i].timestamp); if (pointDate <= yesterday) { comparisonPoint = data[i]; changeLabel = 'Last 24h'; break; } } // If no 24h data, try 12 hours if (!comparisonPoint) { const twelveHoursAgo = new Date(now.getTime() - 12 * 60 * 60 * 1000); for (let i = data.length - 2; i >= 0; i--) { const pointDate = new Date(data[i].timestamp); if (pointDate <= twelveHoursAgo) { comparisonPoint = data[i]; changeLabel = 'Last 12h'; break; } } } // If no 12h data, try 6 hours if (!comparisonPoint) { const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000); for (let i = data.length - 2; i >= 0; i--) { const pointDate = new Date(data[i].timestamp); if (pointDate <= sixHoursAgo) { comparisonPoint = data[i]; changeLabel = 'Last 6h'; break; } } } // If no meaningful time period, use previous data point but only if it's not the first if (!comparisonPoint && data.length >= 3) { comparisonPoint = data[data.length - 2]; const timeDiff = new Date(latest.timestamp).getTime() - new Date(comparisonPoint.timestamp).getTime(); const hoursDiff = Math.round(timeDiff / (1000 * 60 * 60)); if (hoursDiff >= 1) { changeLabel = hoursDiff === 1 ? 'Last Hour' : `Last ${hoursDiff}h`; showRecentChange = true; } } else if (comparisonPoint) { showRecentChange = true; } if (comparisonPoint) { recentChange = currentCount - comparisonPoint.count; } } // Calculate intelligent total growth (only show if meaningful) let totalGrowth = 0; let showTotalGrowth = false; let firstDate = ''; if (data.length >= 3) { // Only show total growth if we have multiple data points totalGrowth = currentCount - data[0].count; firstDate = new Date(data[0].timestamp).toLocaleDateString(); // Only show total growth if it's been more than a few hours const timeSinceStart = new Date().getTime() - new Date(data[0].timestamp).getTime(); const hoursSinceStart = timeSinceStart / (1000 * 60 * 60); showTotalGrowth = hoursSinceStart >= 6; // Show only after 6+ hours of tracking } stats.innerHTML = ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Current Followers</strong><br> <span style="font-size: 28px; color: #e53e3e; font-weight: 600;">${currentCount.toLocaleString()}</span> </div> ${showRecentChange ? ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">${changeLabel}</strong><br> <span style="font-size: 28px; color: ${recentChange >= 0 ? '#4ade80' : '#ef4444'}; font-weight: 600;"> ${recentChange >= 0 ? '+' : ''}${recentChange} </span> </div> ` : ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Recent Change</strong><br> <span style="font-size: 20px; color: #666; font-weight: 600;"> Just started tracking </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Check back later</div> </div> `} <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Daily Average</strong><br> ${dailyStats.hasEnoughData ? ` <span style="font-size: 24px; color: ${dailyStats.dailyAverage >= 0 ? '#4ade80' : '#ef4444'}; font-weight: 600;"> ${dailyStats.dailyAverage >= 0 ? '+' : ''}${dailyStats.dailyAverage.toFixed(1)}/day </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Over ${dailyStats.totalDays} days</div> ` : ` <span style="font-size: 20px; color: #666; font-weight: 600;"> Calculating... </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Need more time</div> `} </div> <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Best Day</strong><br> ${dailyStats.bestDay.growth > 0 ? ` <span style="font-size: 20px; color: #4ade80; font-weight: 600;"> +${dailyStats.bestDay.growth} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">${dailyStats.bestDay.date}</div> ` : ` <span style="font-size: 20px; color: #666; font-weight: 600;"> No data yet </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Keep tracking</div> `} </div> ${showTotalGrowth ? ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Total Growth</strong><br> <span style="font-size: 20px; color: ${totalGrowth >= 0 ? '#4ade80' : '#ef4444'}; font-weight: 600;"> ${totalGrowth >= 0 ? '+' : ''}${totalGrowth} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">Since ${firstDate}</div> </div> ` : ` <div style="min-width: 140px;"> <strong style="color: #ff6b6b;">Tracking Since</strong><br> <span style="font-size: 16px; color: #e53e3e; font-weight: 600;"> ${data.length > 0 ? new Date(data[0].timestamp).toLocaleDateString() : 'Today'} </span> <div style="font-size: 11px; color: #999; margin-top: 2px;">${data.length} data points</div> </div> `} `; // Buttons with dark theme const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;'; // Export button const exportBtn = document.createElement('button'); exportBtn.textContent = '📁 Export Data'; exportBtn.style.cssText = ` background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); color: #e53e3e; border: 1px solid #404040; padding: 12px 18px; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.2s ease; font-family: inherit; `; exportBtn.onmouseover = () => { exportBtn.style.background = 'linear-gradient(135deg, #2d2d2d 0%, #3a3a3a 100%)'; exportBtn.style.color = '#ff6b6b'; }; exportBtn.onmouseout = () => { exportBtn.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%)'; exportBtn.style.color = '#e53e3e'; }; exportBtn.onclick = () => { const dataStr = JSON.stringify(data, 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 = 'fetlife-follower-data.json'; a.click(); URL.revokeObjectURL(url); }; // Clear data button const clearBtn = document.createElement('button'); clearBtn.textContent = '🗑️ Clear Data'; clearBtn.style.cssText = ` background: linear-gradient(135deg, #2d1a1a 0%, #3d2d2d 100%); color: #ef4444; border: 1px solid #404040; padding: 12px 18px; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.2s ease; font-family: inherit; `; clearBtn.onmouseover = () => { clearBtn.style.background = 'linear-gradient(135deg, #3d2d2d 0%, #4a3a3a 100%)'; clearBtn.style.color = '#ff6b6b'; }; clearBtn.onmouseout = () => { clearBtn.style.background = 'linear-gradient(135deg, #2d1a1a 0%, #3d2d2d 100%)'; clearBtn.style.color = '#ef4444'; }; clearBtn.onclick = () => { if (confirm('Are you sure you want to clear all follower data?')) { localStorage.removeItem(STORAGE_KEY); document.body.removeChild(overlay); console.log('📊 Follower data cleared'); } }; buttonContainer.appendChild(exportBtn); buttonContainer.appendChild(clearBtn); // Add controls container const controlsContainer = document.createElement('div'); controlsContainer.style.cssText = ` display: flex; align-items: center; justify-content: center; gap: 20px; margin-top: 15px; flex-wrap: wrap; `; // Milestone toggle const milestoneToggleContainer = document.createElement('div'); milestoneToggleContainer.style.cssText = ` display: flex; align-items: center; gap: 8px; `; const milestoneCheckbox = document.createElement('input'); milestoneCheckbox.type = 'checkbox'; milestoneCheckbox.className = 'milestone-toggle'; milestoneCheckbox.checked = true; milestoneCheckbox.style.cssText = ` width: 16px; height: 16px; accent-color: #e53e3e; cursor: pointer; `; const milestoneLabel = document.createElement('label'); milestoneLabel.textContent = 'Show Milestones'; milestoneLabel.style.cssText = ` color: #999; font-size: 12px; cursor: pointer; user-select: none; `; milestoneLabel.onclick = () => { milestoneCheckbox.checked = !milestoneCheckbox.checked; milestoneCheckbox.dispatchEvent(new Event('change')); }; milestoneCheckbox.onchange = () => { const chartDiv = document.getElementById('followerChart'); if (chartDiv && window.d3) { createD3Chart(chartDiv, currentData); } }; milestoneToggleContainer.appendChild(milestoneCheckbox); milestoneToggleContainer.appendChild(milestoneLabel); // Realistic metrics toggle const realisticToggleContainer = document.createElement('div'); realisticToggleContainer.style.cssText = ` display: flex; align-items: center; gap: 8px; `; const realisticCheckbox = document.createElement('input'); realisticCheckbox.type = 'checkbox'; realisticCheckbox.className = 'realistic-toggle'; realisticCheckbox.checked = false; // Default to showing realistic metrics realisticCheckbox.style.cssText = ` width: 16px; height: 16px; accent-color: #e53e3e; cursor: pointer; `; const realisticLabel = document.createElement('label'); realisticLabel.textContent = 'Show Unrealistic Metrics'; realisticLabel.style.cssText = ` color: #999; font-size: 12px; cursor: pointer; user-select: none; `; // Info button const infoButton = document.createElement('span'); infoButton.textContent = 'ℹ️'; infoButton.style.cssText = ` cursor: help; font-size: 14px; margin-left: 4px; opacity: 0.7; transition: opacity 0.2s ease; `; infoButton.onmouseover = () => { infoButton.style.opacity = '1'; // Remove existing tooltip document.querySelectorAll('.info-tooltip').forEach(t => t.remove()); // Create info tooltip const tooltip = document.createElement('div'); tooltip.className = 'info-tooltip'; tooltip.style.cssText = ` position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); color: #ff6b6b; border: 1px solid #e53e3e; border-radius: 8px; padding: 12px; font-size: 11px; font-family: monospace; white-space: nowrap; z-index: 1002; box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3); max-width: 300px; white-space: normal; line-height: 1.4; `; tooltip.innerHTML = ` <strong>ℹ️ About Unrealistic Metrics:</strong><br> This script only tracks when YOU visit your profile.<br> "Daily Average" assumes constant growth, which isn't realistic.<br> Turn this ON only if you check your profile very frequently. `; realisticToggleContainer.style.position = 'relative'; realisticToggleContainer.appendChild(tooltip); }; infoButton.onmouseout = () => { infoButton.style.opacity = '0.7'; document.querySelectorAll('.info-tooltip').forEach(t => t.remove()); }; realisticLabel.onclick = () => { realisticCheckbox.checked = !realisticCheckbox.checked; realisticCheckbox.dispatchEvent(new Event('change')); }; realisticCheckbox.onchange = () => { updateStatsForTimeRange(currentData); }; realisticToggleContainer.appendChild(realisticCheckbox); realisticToggleContainer.appendChild(realisticLabel); realisticToggleContainer.appendChild(infoButton); controlsContainer.appendChild(milestoneToggleContainer); controlsContainer.appendChild(realisticToggleContainer); // Assemble modal modal.appendChild(closeBtn); modal.appendChild(title); modal.appendChild(timeRangeContainer); modal.appendChild(chartContainer); modal.appendChild(stats); modal.appendChild(buttonContainer); modal.appendChild(controlsContainer); overlay.appendChild(modal); document.body.appendChild(overlay); // Initialize with ALL data updateStatsForTimeRange(currentData); } // Check if we're on a user profile page function isOnProfilePage() { // Check if URL matches a profile pattern const path = window.location.pathname; // Exclude common non-profile pages const excludePatterns = [ '/home', '/feed', '/explore', '/login', '/help', '/conversations', '/inbox', '/events', '/groups', '/fetishes', '/kinksters', '/places', '/pictures', '/videos', '/posts', '/audio', '/guidelines' ]; // Check if path starts with any excluded pattern for (const pattern of excludePatterns) { if (path.startsWith(pattern)) { return false; } } // If path is just a username (like /username123), it's likely a profile const pathParts = path.split('/').filter(part => part.length > 0); if (pathParts.length === 1 && pathParts[0].length > 0) { return true; } // Also check if the page contains profile-specific elements return document.querySelector('[data-component="UserProfile"]') !== null; } // Update chart button text to show current follower count function updateChartButtonText(count) { const button = document.getElementById('chart-viewer-btn'); if (button && count) { button.textContent = `📊 View Chart (${count.toLocaleString()})`; } } // Add view chart button (remove tracking button since we auto-track now) function addChartButton() { // Check if button already exists if (document.getElementById('chart-viewer-btn')) return; const btn = document.createElement('button'); btn.id = 'chart-viewer-btn'; btn.textContent = '📊 View Growth Chart'; btn.style.cssText = ` position: fixed !important; top: 50%; right: 20px !important; transform: translateY(-50%); z-index: 999999 !important; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%) !important; color: #e53e3e !important; border: 1px solid #404040 !important; padding: 12px 18px !important; border-radius: 12px !important; font-weight: 600 !important; font-size: 13px !important; cursor: pointer !important; box-shadow: 0 8px 32px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.1) !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; backdrop-filter: blur(10px) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.5) !important; letter-spacing: 0.3px !important; user-select: none !important; min-width: 200px !important; `; btn.onclick = () => { const data = getStoredData(); showChart(data); }; // Add hover effect btn.onmouseover = () => { btn.style.background = 'linear-gradient(135deg, #2d2d2d 0%, #3a3a3a 100%) !important'; btn.style.color = '#ff6b6b !important'; btn.style.transform = 'translateY(-50%) translateY(-2px) scale(1.02)'; btn.style.boxShadow = '0 12px 40px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.15) !important'; btn.style.borderColor = '#555555 !important'; }; btn.onmouseout = () => { btn.style.background = 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%) !important'; btn.style.color = '#e53e3e !important'; btn.style.transform = 'translateY(-50%) translateY(0) scale(1)'; btn.style.boxShadow = '0 8px 32px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.1) !important'; btn.style.borderColor = '#404040 !important'; }; document.body.appendChild(btn); console.log('📊 Chart button added to page'); } // Initialize the script function init() { console.log('🚀 FetLife Follower Tracker initializing...'); console.log('Current URL:', window.location.href); console.log('Is on profile page:', isOnProfilePage()); console.log('Is on own profile:', isOnOwnProfile()); if (isOnProfilePage()) { console.log('✅ On profile page - adding chart button'); // Only add chart button, remove manual tracking button addChartButton(); // Auto-track followers when on own profile if (isOnOwnProfile()) { console.log('✅ On own profile - starting auto-track'); autoTrackFollowers(); } else { console.log('ℹ️ On someone else\'s profile - no auto-tracking'); } } else { console.log('❌ Not on profile page - no button added'); } } // Wait for page to load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Also run on page navigation (for single page app behavior) let lastUrl = location.href; new MutationObserver(() => { const url = location.href; if (url !== lastUrl) { lastUrl = url; setTimeout(init, 1000); // Delay to allow page to load } }).observe(document, { subtree: true, childList: true }); })();