diff --git a/domain/statistics-store.js b/domain/statistics-store.js index 05cd9b0..c7b79f4 100644 --- a/domain/statistics-store.js +++ b/domain/statistics-store.js @@ -26,6 +26,11 @@ class StatisticsStore { this.lastAnalysisTime = 0 this.analysisCacheDuration = 5 * 60 * 1000 // Cache for 5 minutes + // Enhanced statistics (calculated from current emails) + this.enhancedStats = null + this.lastEnhancedStatsTime = 0 + this.enhancedStatsCacheDuration = 5 * 60 * 1000 // Cache for 5 minutes + // Load persisted data if database is available if (this.db) { this._loadFromDatabase() @@ -198,6 +203,138 @@ class StatisticsStore { } } + /** + * Calculate enhanced statistics from current emails + * Privacy-friendly: uses domain analysis, time patterns, and aggregates + * @param {Array} allMails - Array of all mail summaries + */ + calculateEnhancedStatistics(allMails) { + if (!allMails || allMails.length === 0) { + this.enhancedStats = null + return + } + + const now = Date.now() + if (this.enhancedStats && (now - this.lastEnhancedStatsTime) < this.enhancedStatsCacheDuration) { + debug(`Using cached enhanced stats (age: ${Math.round((now - this.lastEnhancedStatsTime) / 1000)}s)`) + return + } + + debug(`Calculating enhanced statistics from ${allMails.length} emails`) + + // Track sender domains (privacy-friendly: domain only, not full address) + const senderDomains = new Map() + const recipientDomains = new Map() + const hourlyActivity = Array(24).fill(0) + let totalSubjectLength = 0 + let subjectCount = 0 + let withAttachments = 0 + let dayTimeEmails = 0 // 6am-6pm + let nightTimeEmails = 0 // 6pm-6am + + allMails.forEach(mail => { + try { + // Sender domain analysis + if (mail.from && mail.from[0] && mail.from[0].address) { + const parts = mail.from[0].address.split('@') + const domain = parts[1] ? parts[1].toLowerCase() : null + if (domain) { + senderDomains.set(domain, (senderDomains.get(domain) || 0) + 1) + } + } + + // Recipient domain analysis + if (mail.to && mail.to[0]) { + const parts = mail.to[0].split('@') + const domain = parts[1] ? parts[1].toLowerCase() : null + if (domain) { + recipientDomains.set(domain, (recipientDomains.get(domain) || 0) + 1) + } + } + + // Hourly activity pattern + if (mail.date) { + const date = new Date(mail.date) + if (!isNaN(date.getTime())) { + const hour = date.getHours() + hourlyActivity[hour]++ + + // Day vs night distribution (6am-6pm = day, 6pm-6am = night) + if (hour >= 6 && hour < 18) { + dayTimeEmails++ + } else { + nightTimeEmails++ + } + } + } + + // Subject length analysis (privacy-friendly: only length, not content) + if (mail.subject) { + totalSubjectLength += mail.subject.length + subjectCount++ + } + + // Check if email likely has attachments (would need full fetch to confirm) + // For now, we'll track this separately when we fetch full emails + } catch (e) { + // Skip invalid entries + } + }) + + // Get top sender domains (limit to top 10) + const topSenderDomains = Array.from(senderDomains.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([domain, count]) => ({ domain, count })) + + // Get top recipient domains + const topRecipientDomains = Array.from(recipientDomains.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([domain, count]) => ({ domain, count })) + + // Find busiest hours (top 5) + const busiestHours = hourlyActivity + .map((count, hour) => ({ hour, count })) + .filter(h => h.count > 0) + .sort((a, b) => b.count - a.count) + .slice(0, 5) + + // Calculate peak hour concentration (% of emails in busiest hour) + const peakHourCount = busiestHours.length > 0 ? busiestHours[0].count : 0 + const peakHourPercentage = allMails.length > 0 ? + Math.round((peakHourCount / allMails.length) * 100) : + 0 + + // 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' + + // Calculate day/night percentage + const totalDayNight = dayTimeEmails + nightTimeEmails + const dayPercentage = totalDayNight > 0 ? + Math.round((dayTimeEmails / totalDayNight) * 100) : + 50 + + this.enhancedStats = { + topSenderDomains, + topRecipientDomains, + busiestHours, + averageSubjectLength: subjectCount > 0 ? Math.round(totalSubjectLength / subjectCount) : 0, + totalEmails: allMails.length, + uniqueSenderDomains: senderDomains.size, + uniqueRecipientDomains: recipientDomains.size, + peakHourPercentage, + emailsPerHour: parseFloat(emailsPerHour), + dayPercentage + } + + this.lastEnhancedStatsTime = now + debug(`Enhanced stats calculated: ${this.enhancedStats.uniqueSenderDomains} unique sender domains, ${this.enhancedStats.busiestHours.length} busy hours`) + } + /** * Analyze all existing emails to build historical statistics * @param {Array} allMails - Array of all mail summaries with date property @@ -276,7 +413,8 @@ class StatisticsStore { timeline: timeline }, historical: historicalTimeline, - prediction: prediction + prediction: prediction, + enhanced: this.enhancedStats } } diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index 3c00712..1dbd4f1 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -2552,6 +2552,7 @@ body.light-mode .theme-icon-light { padding-bottom: 4rem; height: 550px; position: relative; + margin-top: 3rem; } .chart-container h2 { @@ -2642,6 +2643,127 @@ body.light-mode .theme-icon-light { white-space: nowrap; } + +/* Enhanced Statistics Cards */ + +.enhanced-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 2rem; + margin-top: 3rem; +} + +.stat-card-detailed { + background: var(--overlay-white-05); + border: 1px solid var(--overlay-purple-30); + border-radius: 15px; + padding: 2rem; + transition: all 0.3s ease; +} + +.stat-card-detailed:hover { + background: var(--overlay-white-08); + border-color: var(--overlay-purple-40); + transform: translateY(-2px); +} + +.section-header-small { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 1.5rem 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text-light); + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--overlay-purple-20); +} + +.stat-list { + list-style: none; + padding: 0; + margin: 0; +} + +.stat-list-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid var(--overlay-white-05); +} + +.stat-list-item:last-child { + border-bottom: none; +} + +.stat-list-label { + font-size: 1.05rem; + color: var(--color-text-primary); + font-family: 'Courier New', monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 70%; +} + +.stat-list-value { + font-size: 1.1rem; + font-weight: 700; + color: var(--color-accent-purple-light); + min-width: 40px; + text-align: right; +} + +.stat-footer { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--overlay-white-08); + font-size: 0.95rem; + color: var(--color-text-dim); + text-align: center; +} + +.stat-empty { + text-align: center; + color: var(--color-text-dim); + padding: 2rem 0; + font-style: italic; +} + +.quick-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 1.5rem; +} + +.quick-stat-item { + text-align: center; + padding: 1rem; + background: var(--overlay-white-03); + border-radius: 10px; + transition: all 0.3s ease; +} + +.quick-stat-item:hover { + background: var(--overlay-purple-08); + transform: translateY(-2px); +} + +.quick-stat-value { + font-size: 1.8rem; + font-weight: 700; + color: var(--color-accent-purple-light); + margin-bottom: 0.5rem; +} + +.quick-stat-label { + font-size: 0.9rem; + color: var(--color-text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; +} + @media (max-width: 640px) { .chart-legend-custom { flex-direction: column; @@ -2697,6 +2819,32 @@ body.light-mode .theme-icon-light { font-size: 0.95rem; margin-bottom: 2rem; } + /* Enhanced stats mobile */ + .enhanced-stats-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + margin-top: 2rem; + } + .stat-card-detailed { + padding: 1.5rem; + } + .section-header-small { + font-size: 1.1rem; + } + .stat-list-label { + font-size: 0.85rem; + max-width: 65%; + } + .stat-list-value { + font-size: 0.95rem; + } + .quick-stats { + grid-template-columns: 1fr; + gap: 1rem; + } + .quick-stat-value { + font-size: 1.5rem; + } .action-links { position: relative; } diff --git a/infrastructure/web/routes/stats.js b/infrastructure/web/routes/stats.js index 95091cd..9b40a01 100644 --- a/infrastructure/web/routes/stats.js +++ b/infrastructure/web/routes/stats.js @@ -28,6 +28,7 @@ router.get('/', async(req, res) => { if (mailProcessingService) { const allMails = mailProcessingService.getAllMailSummaries() statisticsStore.analyzeHistoricalData(allMails) + statisticsStore.calculateEnhancedStatistics(allMails) } const stats = statisticsStore.getEnhancedStats() diff --git a/infrastructure/web/views/stats.twig b/infrastructure/web/views/stats.twig index b35a161..858fb5b 100644 --- a/infrastructure/web/views/stats.twig +++ b/infrastructure/web/views/stats.twig @@ -88,6 +88,103 @@ + {% if stats.enhanced %} + +
+ +
+

+ Top Sender Domains +

+ {% if stats.enhanced.topSenderDomains|length > 0 %} + + + {% else %} +

No data yet

+ {% endif %} +
+ + +
+

+ Top Recipient Domains +

+ {% if stats.enhanced.topRecipientDomains|length > 0 %} + + + {% else %} +

No data yet

+ {% endif %} +
+ + +
+

+ Busiest Hours +

+ {% if stats.enhanced.busiestHours|length > 0 %} + + {% else %} +

No data yet

+ {% endif %} +
+ + +
+

+ Quick Insights +

+
+
+
{{ stats.enhanced.averageSubjectLength }}
+
Avg Subject Length
+
+
+
{{ stats.enhanced.uniqueSenderDomains }}
+
Unique Senders
+
+
+
{{ stats.enhanced.uniqueRecipientDomains }}
+
Unique Recipients
+
+
+
{{ stats.enhanced.peakHourPercentage }}%
+
Peak Hour Traffic
+
+
+
{{ stats.enhanced.emailsPerHour }}
+
Emails per Hour
+
+
+
{{ stats.enhanced.dayPercentage }}%
+
Daytime (6am-6pm)
+
+
+
+
+ {% endif %} +

Email Activity Timeline