From 197d9b923ee758ecb819024d401fb809958042b8 Mon Sep 17 00:00:00 2001 From: ClaraCrazy Date: Mon, 5 Jan 2026 05:21:18 +0100 Subject: [PATCH] [Feat]: Bring back performance V2 Electric Boogaloo --- application/config.js | 6 - domain/statistics-store.js | 6 +- .../web/public/javascripts/stats.js | 168 +++++++++++++++++- .../web/public/javascripts/utils.js | 56 +++++- .../web/public/stylesheets/custom.css | 1 + infrastructure/web/routes/account.js | 1 + infrastructure/web/routes/auth.js | 1 + infrastructure/web/routes/stats.js | 94 ++++++---- infrastructure/web/template-context.js | 4 +- infrastructure/web/views/account.twig | 60 +------ infrastructure/web/views/auth.twig | 4 +- infrastructure/web/views/stats.twig | 8 +- 12 files changed, 300 insertions(+), 109 deletions(-) diff --git a/application/config.js b/application/config.js index 01241f9..171542d 100644 --- a/application/config.js +++ b/application/config.js @@ -50,12 +50,6 @@ const config = { }, blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || [], features: { - imap: { - enabled: true, // IMAP is always required - refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS), - fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100, - fetchConcurrency: Number(process.env.IMAP_CONCURRENCY) || 6 - }, smtp: parseBool(process.env.SMTP_ENABLED) || false } }, diff --git a/domain/statistics-store.js b/domain/statistics-store.js index c7b79f4..14b5539 100644 --- a/domain/statistics-store.js +++ b/domain/statistics-store.js @@ -309,8 +309,8 @@ class StatisticsStore { // Calculate emails per hour rate (average across all active hours) const activeHours = hourlyActivity.filter(count => count > 0).length const emailsPerHour = activeHours > 0 ? - (allMails.length / activeHours).toFixed(1) : - '0.0' + Math.round(allMails.length / activeHours) : + 0 // Calculate day/night percentage const totalDayNight = dayTimeEmails + nightTimeEmails @@ -327,7 +327,7 @@ class StatisticsStore { uniqueSenderDomains: senderDomains.size, uniqueRecipientDomains: recipientDomains.size, peakHourPercentage, - emailsPerHour: parseFloat(emailsPerHour), + emailsPerHour: emailsPerHour, dayPercentage } diff --git a/infrastructure/web/public/javascripts/stats.js b/infrastructure/web/public/javascripts/stats.js index e780b51..9cc12c5 100644 --- a/infrastructure/web/public/javascripts/stats.js +++ b/infrastructure/web/public/javascripts/stats.js @@ -3,6 +3,10 @@ * Handles Chart.js initialization with historical, real-time, and predicted data */ +// Store chart instance globally for updates +let statsChart = null; +let chartContext = null; + // Initialize stats chart if on stats page document.addEventListener('DOMContentLoaded', function() { const chartCanvas = document.getElementById('statsChart'); @@ -20,6 +24,12 @@ document.addEventListener('DOMContentLoaded', function() { console.log(`Loaded data: ${historicalData.length} historical, ${realtimeData.length} realtime, ${predictionData.length} predictions`); + // If no data yet (lazy loading), add a placeholder message + const hasData = historicalData.length > 0 || realtimeData.length > 0 || predictionData.length > 0; + if (!hasData) { + console.log('No chart data yet - will populate after lazy load'); + } + // Set up Socket.IO connection for real-time updates if (typeof io !== 'undefined') { const socket = io(); @@ -63,6 +73,7 @@ document.addEventListener('DOMContentLoaded', function() { // Create gradient for fading effect on historical data const ctx = chartCanvas.getContext('2d'); + chartContext = ctx; const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0); historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)'); historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)'); @@ -70,7 +81,7 @@ document.addEventListener('DOMContentLoaded', function() { // Track visibility state for each dataset const datasetVisibility = [true, true, true]; - const chart = new Chart(ctx, { + statsChart = new Chart(ctx, { type: 'line', data: { labels: labels, @@ -132,7 +143,9 @@ document.addEventListener('DOMContentLoaded', function() { intersect: false, callbacks: { title: function(context) { + if (!context || !context[0] || context[0].dataIndex === undefined) return ''; const dataIndex = context[0].dataIndex; + if (!allTimePoints[dataIndex]) return ''; const point = allTimePoints[dataIndex]; const date = new Date(point.timestamp); return date.toLocaleString('en-US', { @@ -218,8 +231,8 @@ document.addEventListener('DOMContentLoaded', function() { this.classList.toggle('active'); // Toggle dataset visibility with fade effect - const meta = chart.getDatasetMeta(index); - const dataset = chart.data.datasets[index]; + const meta = statsChart.getDatasetMeta(index); + const dataset = statsChart.data.datasets[index]; if (isActive) { // Fade out @@ -231,7 +244,154 @@ document.addEventListener('DOMContentLoaded', function() { datasetVisibility[index] = true; } - chart.update('active'); + statsChart.update('active'); }); }); + + // Lazy load full stats data if placeholder detected + lazyLoadStats(); }); + +/** + * Rebuild chart with new data + */ +function rebuildStatsChart() { + if (!statsChart || !chartContext) { + console.log('Chart not initialized, skipping rebuild'); + return; + } + + const realtimeData = window.initialStatsData || []; + const historicalData = window.historicalData || []; + const predictionData = window.predictionData || []; + + console.log(`Rebuilding chart with: ${historicalData.length} historical, ${realtimeData.length} realtime, ${predictionData.length} predictions`); + + const allTimePoints = [ + ...historicalData.map(d => ({...d, type: 'historical' })), + ...realtimeData.map(d => ({...d, type: 'realtime' })), + ...predictionData.map(d => ({...d, type: 'prediction' })) + ].sort((a, b) => a.timestamp - b.timestamp); + + if (allTimePoints.length === 0) { + console.log('No data points to chart'); + return; + } + + // Create labels + const labels = allTimePoints.map(d => { + const date = new Date(d.timestamp); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }); + + // Prepare datasets + const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null); + const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null); + const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null); + + // Update chart data + statsChart.data.labels = labels; + statsChart.data.datasets[0].data = historicalPoints; + statsChart.data.datasets[1].data = realtimePoints; + statsChart.data.datasets[2].data = predictionPoints; + + // Update the chart + statsChart.update(); +} + +/** + * Lazy load full statistics data and update DOM + */ +function lazyLoadStats() { + // Check if this is a lazy-loaded page (has placeholder data) + const currentCountEl = document.getElementById('currentCount'); + if (!currentCountEl) { + console.log('Stats lazy load: currentCount element not found'); + return; + } + + const currentText = currentCountEl.textContent.trim(); + console.log('Stats lazy load: current count text is:', currentText); + + if (currentText !== '...') { + console.log('Stats lazy load: already loaded with real data, skipping'); + return; // Already loaded with real data + } + + console.log('Stats lazy load: fetching data from /stats/api'); + + fetch('/stats/api') + .then(response => response.json()) + .then(data => { + console.log('Stats lazy load: received data', data); + // Update main stat cards + document.getElementById('currentCount').textContent = data.currentCount || '0'; + document.getElementById('historicalTotal').textContent = data.allTimeTotal || '0'; + document.getElementById('receives24h').textContent = (data.last24Hours && data.last24Hours.receives) || '0'; + document.getElementById('deletes24h').textContent = (data.last24Hours && data.last24Hours.deletes) || '0'; + document.getElementById('forwards24h').textContent = (data.last24Hours && data.last24Hours.forwards) || '0'; + + // Update enhanced stats if available + if (data.enhanced) { + const topSenderDomains = document.querySelector('[data-stats="top-sender-domains"]'); + const topRecipientDomains = document.querySelector('[data-stats="top-recipient-domains"]'); + + if (topSenderDomains && data.enhanced.topSenderDomains && data.enhanced.topSenderDomains.length > 0) { + let html = ''; + data.enhanced.topSenderDomains.slice(0, 5).forEach(item => { + html += `
  • ${item.domain}${item.count}
  • `; + }); + topSenderDomains.innerHTML = html; + } + + if (topRecipientDomains && data.enhanced.topRecipientDomains && data.enhanced.topRecipientDomains.length > 0) { + let html = ''; + data.enhanced.topRecipientDomains.slice(0, 5).forEach(item => { + html += `
  • ${item.domain}${item.count}
  • `; + }); + topRecipientDomains.innerHTML = html; + } + + // Update unique domains count + const uniqueSenderDomains = document.querySelector('[data-stats="unique-sender-domains"]'); + if (uniqueSenderDomains && data.enhanced.uniqueSenderDomains) { + uniqueSenderDomains.textContent = data.enhanced.uniqueSenderDomains; + } + + // Update average email size + const avgSize = document.querySelector('[data-stats="average-email-size"]'); + if (avgSize && data.enhanced.averageEmailSize) { + avgSize.textContent = data.enhanced.averageEmailSize; + } + + // Update peak hour + const peakHour = document.querySelector('[data-stats="peak-hour"]'); + if (peakHour && data.enhanced.peakHour) { + peakHour.textContent = data.enhanced.peakHour; + } + + // Update 24h prediction + const prediction24h = document.querySelector('[data-stats="prediction-24h"]'); + if (prediction24h && data.enhanced.prediction24h) { + prediction24h.textContent = data.enhanced.prediction24h; + } + } + + // Update window data for charts + window.initialStatsData = (data.last24Hours && data.last24Hours.timeline) || []; + window.historicalData = data.historical || []; + window.predictionData = data.prediction || []; + + // Rebuild chart with new data + rebuildStatsChart(); + }) + .catch(error => { + console.error('Error loading stats:', error); + // Stats remain as placeholder + }); +} diff --git a/infrastructure/web/public/javascripts/utils.js b/infrastructure/web/public/javascripts/utils.js index 9c1e935..26fa540 100644 --- a/infrastructure/web/public/javascripts/utils.js +++ b/infrastructure/web/public/javascripts/utils.js @@ -498,8 +498,61 @@ document.addEventListener('DOMContentLoaded', () => { refreshTimer.textContent = refreshInterval; } + function initAccountModals() { + // Add Email Modal + const addEmailBtn = document.getElementById('addEmailBtn'); + const addEmailModal = document.getElementById('addEmailModal'); + const closeAddEmail = document.getElementById('closeAddEmail'); + + if (addEmailBtn && addEmailModal) { + addEmailBtn.onclick = function() { + addEmailModal.style.display = 'block'; + }; + } + + if (closeAddEmail && addEmailModal) { + closeAddEmail.onclick = function() { + addEmailModal.style.display = 'none'; + }; + } + + // Delete Account Modal + const deleteAccountBtn = document.getElementById('deleteAccountBtn'); + const deleteAccountModal = document.getElementById('deleteAccountModal'); + const closeDeleteAccount = document.getElementById('closeDeleteAccount'); + const cancelDelete = document.getElementById('cancelDelete'); + + if (deleteAccountBtn && deleteAccountModal) { + deleteAccountBtn.onclick = function() { + deleteAccountModal.style.display = 'block'; + }; + } + + if (closeDeleteAccount && deleteAccountModal) { + closeDeleteAccount.onclick = function() { + deleteAccountModal.style.display = 'none'; + }; + } + + if (cancelDelete && deleteAccountModal) { + cancelDelete.onclick = function() { + deleteAccountModal.style.display = 'none'; + }; + } + + // Window click handler for both modals + window.addEventListener('click', function(e) { + if (addEmailModal && e.target === addEmailModal) { + addEmailModal.style.display = 'none'; + } + if (deleteAccountModal && e.target === deleteAccountModal) { + deleteAccountModal.style.display = 'none'; + } + }); + } + // Expose utilities and run them - window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal }; + window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal, initAccountModals }; formatEmailDates(); formatMailDate(); initLockModals(); @@ -509,4 +562,5 @@ document.addEventListener('DOMContentLoaded', () => { initThemeToggle(); initForwardModal(); initCryptoKeysToggle(); + initAccountModals(); }); diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index 905da0f..383b77d 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -2115,6 +2115,7 @@ label { } .close { + text-align: right; float: right; font-size: 2.8rem; font-weight: bold; diff --git a/infrastructure/web/routes/account.js b/infrastructure/web/routes/account.js index 5307bef..141793d 100644 --- a/infrastructure/web/routes/account.js +++ b/infrastructure/web/routes/account.js @@ -37,6 +37,7 @@ router.get('/account', requireAuth, async(req, res) => { stats, branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'], purgeTime: purgeTime, + smtpEnabled: config.email.features.smtp, successMessage: req.session.accountSuccess, errorMessage: req.session.accountError }) diff --git a/infrastructure/web/routes/auth.js b/infrastructure/web/routes/auth.js index 2daecb1..7d55bf1 100644 --- a/infrastructure/web/routes/auth.js +++ b/infrastructure/web/routes/auth.js @@ -107,6 +107,7 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => { title: `Login or Register | ${(config.http.features.branding || ['48hr.email'])[0]}`, branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'], purgeTime: purgeTime, + smtpEnabled: config.email.features.smtp, errorMessage, successMessage }) diff --git a/infrastructure/web/routes/stats.js b/infrastructure/web/routes/stats.js index e32dfc9..5636a9f 100644 --- a/infrastructure/web/routes/stats.js +++ b/infrastructure/web/routes/stats.js @@ -2,7 +2,7 @@ const express = require('express') const router = new express.Router() const debug = require('debug')('48hr-email:stats-routes') -// GET /stats - Statistics page +// GET /stats - Statistics page with lazy loading router.get('/', async(req, res) => { try { const config = req.app.get('config') @@ -16,6 +16,61 @@ router.get('/', async(req, res) => { return res.redirect(redirectUrl) } + const Helper = require('../../../application/helper') + const helper = new Helper() + const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'] + const purgeTime = helper.purgeTimeElemetBuilder() + + // Return page with placeholder data immediately - real data loads via JS + const placeholderStats = { + currentCount: '...', + allTimeTotal: '...', + last24Hours: { + receives: '...', + deletes: '...', + forwards: '...', + timeline: [] + }, + enhanced: { + topSenderDomains: [], + topRecipientDomains: [], + uniqueSenderDomains: '...', + averageEmailSize: '...', + peakHour: '...', + prediction24h: '...' + }, + historical: [], + prediction: [] + } + + debug(`Stats page requested - returning with lazy loading`) + + res.render('stats', { + title: `Statistics | ${branding[0]}`, + branding: branding, + purgeTime: purgeTime, + stats: placeholderStats, + authEnabled: config.user.authEnabled, + currentUser: req.session && req.session.username, + lazyLoad: true + }) + } catch (error) { + debug(`Error loading stats page: ${error.message}`) + console.error('Error while loading stats page', error) + res.status(500).send('Error loading statistics') + } +}) + +// GET /stats/api - JSON API for lazy-loaded stats (full calculation) +router.get('/api', async(req, res) => { + try { + const config = req.app.get('config') + + // Check if statistics are enabled + if (!config.http.features.statistics) { + return res.status(403).json({ error: 'Statistics are disabled' }) + } + const statisticsStore = req.app.get('statisticsStore') const imapService = req.app.get('imapService') const mailProcessingService = req.app.get('mailProcessingService') @@ -36,46 +91,13 @@ router.get('/', async(req, res) => { } const stats = statisticsStore.getEnhancedStats() - const purgeTime = helper.purgeTimeElemetBuilder() - const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'] - debug(`Stats page requested: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total, ${stats.historical.length} historical points`) - - res.render('stats', { - title: `Statistics | ${branding[0]}`, - branding: branding, - purgeTime: purgeTime, - stats: stats, - authEnabled: config.user.authEnabled, - currentUser: req.session && req.session.username - }) - } catch (error) { - debug(`Error loading stats page: ${error.message}`) - console.error('Error while loading stats page', error) - res.status(500).send('Error loading statistics') - } -}) - -// GET /stats/api - JSON API for real-time updates -router.get('/api', async(req, res) => { - try { - const statisticsStore = req.app.get('statisticsStore') - const imapService = req.app.get('imapService') - const Helper = require('../../../application/helper') - const helper = new Helper() - - // Update largest UID before getting stats (if IMAP is ready) - if (imapService) { - const largestUid = await helper.getLargestUid(imapService) - statisticsStore.updateLargestUid(largestUid) - } - - // Use lightweight stats - no historical analysis on API calls - const stats = statisticsStore.getLightweightStats() + debug(`Stats API returned: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total`) res.json(stats) } catch (error) { debug(`Error fetching stats API: ${error.message}`) + console.error('Stats API error:', error) res.status(500).json({ error: 'Failed to fetch statistics' }) } }) diff --git a/infrastructure/web/template-context.js b/infrastructure/web/template-context.js index fa506a5..d274c95 100644 --- a/infrastructure/web/template-context.js +++ b/infrastructure/web/template-context.js @@ -9,6 +9,8 @@ class TemplateContext { constructor() { this.helper = new Helper() this.purgeTime = this.helper.purgeTimeElemetBuilder() + // Cache domains to avoid reprocessing on every request + this.cachedDomains = this.helper.getDomains() } /** @@ -34,7 +36,7 @@ class TemplateContext { currentUser: req.session && req.session.username ? req.session.username : null, // Common data - domains: this.helper.getDomains(), + domains: this.cachedDomains, example: config.email.examples.account } } diff --git a/infrastructure/web/views/account.twig b/infrastructure/web/views/account.twig index 51b7a27..e5f3b8c 100644 --- a/infrastructure/web/views/account.twig +++ b/infrastructure/web/views/account.twig @@ -44,10 +44,12 @@

    Account Overview

    + {% if smtpEnabled %}
    {{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}
    Forward Emails
    + {% endif %}
    {{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}
    Locked Inboxes
    @@ -59,6 +61,7 @@
    + {% if smtpEnabled %} + {% endif %} +{% if smtpEnabled %} - - +{% endif %} {% endblock %} diff --git a/infrastructure/web/views/auth.twig b/infrastructure/web/views/auth.twig index 6f93248..1d05527 100644 --- a/infrastructure/web/views/auth.twig +++ b/infrastructure/web/views/auth.twig @@ -123,9 +123,11 @@

    Account Benefits

    + {% if smtpEnabled %}
    Forward emails to verified addresses
    -
    Lock up to 5 inboxes to your account
    Manage multiple forwarding destinations
    + {% endif %} +
    Lock up to 5 inboxes to your account
    Access your locked inboxes anywhere

    No account needed for basic temporary inboxes • Browse as guest

    diff --git a/infrastructure/web/views/stats.twig b/infrastructure/web/views/stats.twig index 858fb5b..f1405ed 100644 --- a/infrastructure/web/views/stats.twig +++ b/infrastructure/web/views/stats.twig @@ -97,7 +97,7 @@ Top Sender Domains {% if stats.enhanced.topSenderDomains|length > 0 %} -
      +
        {% for item in stats.enhanced.topSenderDomains|slice(0, 5) %}
      • {{ item.domain }} @@ -105,7 +105,7 @@
      • {% endfor %}
      - + {% else %}

      No data yet

      {% endif %} @@ -117,7 +117,7 @@ Top Recipient Domains {% if stats.enhanced.topRecipientDomains|length > 0 %} -
        +
          {% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %}
        • {{ item.domain }} @@ -125,7 +125,7 @@
        • {% endfor %}
        - + {% else %}

        No data yet

        {% endif %}