diff --git a/server.js b/server.js index e8237e5..2b344c1 100644 --- a/server.js +++ b/server.js @@ -263,6 +263,82 @@ function logErrorOnce(category, message) { return false; } +// ============================================ +// VISITOR TRACKING +// ============================================ +// Lightweight in-memory visitor counter — tracks unique IPs per day +// No cookies, no external analytics, no persistent storage +// Resets on server restart; logs daily summary + +const visitorStats = { + today: new Date().toISOString().slice(0, 10), // YYYY-MM-DD + uniqueIPs: new Set(), + totalRequests: 0, + allTimeVisitors: 0, // Cumulative unique visitors since server start + allTimeRequests: 0, // Cumulative requests since server start + serverStarted: new Date().toISOString(), + history: [] // Last 30 days of { date, uniqueVisitors, totalRequests } +}; + +function rolloverVisitorStats() { + const now = new Date().toISOString().slice(0, 10); + if (now !== visitorStats.today) { + // Save yesterday's stats to history + visitorStats.history.push({ + date: visitorStats.today, + uniqueVisitors: visitorStats.uniqueIPs.size, + totalRequests: visitorStats.totalRequests + }); + // Keep only last 30 days + if (visitorStats.history.length > 30) { + visitorStats.history = visitorStats.history.slice(-30); + } + const avg = visitorStats.history.length > 0 + ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) + : 0; + console.log(`[Visitors] Daily summary for ${visitorStats.today}: ${visitorStats.uniqueIPs.size} unique visitors, ${visitorStats.totalRequests} requests | All-time: ${visitorStats.allTimeVisitors} visitors, ${visitorStats.allTimeRequests} requests | ${visitorStats.history.length}-day avg: ${avg}/day`); + // Reset daily counters for new day + visitorStats.today = now; + visitorStats.uniqueIPs = new Set(); + visitorStats.totalRequests = 0; + } +} + +// Visitor tracking middleware — only counts page loads and API config fetches +// (not every API poll, which would inflate the count) +app.use((req, res, next) => { + rolloverVisitorStats(); + + // Only count meaningful "visits" — initial page load or config fetch + // This avoids counting every 5-second DX cluster poll as a "visit" + const countableRoutes = ['/', '/index.html', '/api/config']; + if (countableRoutes.includes(req.path)) { + const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown'; + const isNew = !visitorStats.uniqueIPs.has(ip); + visitorStats.uniqueIPs.add(ip); + visitorStats.totalRequests++; + visitorStats.allTimeRequests++; + + if (isNew) { + visitorStats.allTimeVisitors++; + logInfo(`[Visitors] New visitor today (#${visitorStats.uniqueIPs.size}, #${visitorStats.allTimeVisitors} all-time) from ${ip.replace(/\d+$/, 'x')}`); + } + } + + next(); +}); + +// Log visitor count every hour +setInterval(() => { + rolloverVisitorStats(); + if (visitorStats.uniqueIPs.size > 0 || visitorStats.allTimeVisitors > 0) { + const avg = visitorStats.history.length > 0 + ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) + : visitorStats.uniqueIPs.size; + console.log(`[Visitors] Today so far: ${visitorStats.uniqueIPs.size} unique, ${visitorStats.totalRequests} requests | All-time: ${visitorStats.allTimeVisitors} visitors | Avg: ${avg}/day`); + } +}, 60 * 60 * 1000); + // Serve static files // dist/ contains the built React app (from npm run build) // public/ contains the fallback page if build hasn't run @@ -3593,11 +3669,29 @@ function getLastWeekendOfMonth(year, month) { // ============================================ app.get('/api/health', (req, res) => { + rolloverVisitorStats(); + const avg = visitorStats.history.length > 0 + ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) + : visitorStats.uniqueIPs.size; res.json({ status: 'ok', version: APP_VERSION, uptime: process.uptime(), - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + visitors: { + today: { + date: visitorStats.today, + uniqueVisitors: visitorStats.uniqueIPs.size, + totalRequests: visitorStats.totalRequests + }, + allTime: { + since: visitorStats.serverStarted, + uniqueVisitors: visitorStats.allTimeVisitors, + totalRequests: visitorStats.allTimeRequests + }, + dailyAverage: avg, + history: visitorStats.history + } }); });