[Chore]: Rename legacy functions

This commit is contained in:
ClaraCrazy 2026-01-06 16:03:08 +01:00
parent 79679af9bc
commit d8b19dcd26
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
5 changed files with 433 additions and 150 deletions

View file

@ -1,152 +1,402 @@
const debug = require('debug')('48hr-email:stats-store') const debug = require('debug')('48hr-email:stats-store');
const config = require('../application/config') const config = require('../application/config');
/** /**
* Statistics Store - Tracks email metrics and historical data * Statistics Store - Tracks email metrics and historical data
* Stores 24-hour rolling statistics for receives, deletes, and forwards * Stores rolling statistics for receives, deletes, and forwards over the configured purge window
* Persists data to database for survival across restarts * Persists data to database for survival across restarts
*/ */
class StatisticsStore { class StatisticsStore {
constructor(db = null) { constructor(db = null) {
this.db = db this.db = db;
this.currentCount = 0;
// Current totals this.largestUid = 0;
this.currentCount = 0 this.hourlyData = [];
this.largestUid = 0 this.maxDataPoints = 1440; // Default: 1440 minutes (24 hours), but actual retention is purge window
this.lastCleanup = Date.now();
// 24-hour rolling data (one entry per minute = 1440 entries) this.historicalData = null;
this.hourlyData = [] this.lastAnalysisTime = 0;
this.maxDataPoints = 24 * 60 // 24 hours * 60 minutes this.analysisCacheDuration = 5 * 60 * 1000; // Cache for 5 minutes
this.enhancedStats = null;
// Track last cleanup to avoid too frequent operations this.lastEnhancedStatsTime = 0;
this.lastCleanup = Date.now() this.enhancedStatsCacheDuration = 5 * 60 * 1000; // Cache for 5 minutes
// Historical data caching to prevent repeated analysis
this.historicalData = null
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) { if (this.db) {
this._loadFromDatabase() this._loadFromDatabase();
}
debug('Statistics store initialized');
} }
debug('Statistics store initialized')
}
/**
* Get cutoff time based on email purge configuration
* @returns {number} Timestamp in milliseconds
* @private
*/
_getPurgeCutoffMs() { _getPurgeCutoffMs() {
const time = config.email.purgeTime.time const time = config.email.purgeTime.time;
const unit = config.email.purgeTime.unit const unit = config.email.purgeTime.unit;
let cutoffMs = 0;
let cutoffMs = 0
switch (unit) { switch (unit) {
case 'minutes': case 'minutes':
cutoffMs = time * 60 * 1000 cutoffMs = time * 60 * 1000;
break break;
case 'hours': case 'hours':
cutoffMs = time * 60 * 60 * 1000 cutoffMs = time * 60 * 60 * 1000;
break break;
case 'days': case 'days':
cutoffMs = time * 24 * 60 * 60 * 1000 cutoffMs = time * 24 * 60 * 60 * 1000;
break break;
default: default:
cutoffMs = 48 * 60 * 60 * 1000 // Fallback to 48 hours cutoffMs = 48 * 60 * 60 * 1000; // Fallback to 48 hours
}
return cutoffMs;
} }
return cutoffMs
}
/**
* Load statistics from database
* @private
*/
_loadFromDatabase() { _loadFromDatabase() {
try { try {
const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE id = 1') const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE id = 1');
const row = stmt.get() const row = stmt.get();
if (row) { if (row) {
this.largestUid = row.largest_uid || 0 this.largestUid = row.largest_uid || 0;
// Parse hourly data
if (row.hourly_data) { if (row.hourly_data) {
try { try {
const parsed = JSON.parse(row.hourly_data) const parsed = JSON.parse(row.hourly_data);
// Filter out stale data based on config purge time const cutoff = Date.now() - this._getPurgeCutoffMs();
const cutoff = Date.now() - this._getPurgeCutoffMs() this.hourlyData = parsed.filter(entry => entry.timestamp >= cutoff);
this.hourlyData = parsed.filter(entry => entry.timestamp >= cutoff) debug(`Loaded ${this.hourlyData.length} hourly data points from database (cutoff: ${new Date(cutoff).toISOString()})`);
debug(`Loaded ${this.hourlyData.length} hourly data points from database (cutoff: ${new Date(cutoff).toISOString()})`)
} catch (e) { } catch (e) {
debug('Failed to parse hourly data:', e.message) debug('Failed to parse hourly data:', e.message);
this.hourlyData = [] this.hourlyData = [];
} }
} }
debug(`Loaded from database: largestUid=${this.largestUid}, hourlyData=${this.hourlyData.length} entries`);
debug(`Loaded from database: largestUid=${this.largestUid}, hourlyData=${this.hourlyData.length} entries`)
} }
} catch (error) { } catch (error) {
debug('Failed to load statistics from database:', error.message) debug('Failed to load statistics from database:', error.message);
} }
} }
/**
* Save statistics to database
* @private
*/
_saveToDatabase() { _saveToDatabase() {
if (!this.db) return if (!this.db) return;
try { try {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE statistics UPDATE statistics
SET largest_uid = ?, hourly_data = ?, last_updated = ? SET largest_uid = ?, hourly_data = ?, last_updated = ?
WHERE id = 1 WHERE id = 1
`) `);
stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now()) stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now());
debug('Statistics saved to database') debug('Statistics saved to database');
} catch (error) { } catch (error) {
debug('Failed to save statistics to database:', error.message) debug('Failed to save statistics to database:', error.message);
} }
} }
/**
* Initialize with current email count
* @param {number} count - Current email count
*/
initialize(count) { initialize(count) {
this.currentCount = count this.currentCount = count;
debug(`Initialized with ${count} emails`) debug(`Initialized with ${count} emails`);
} }
/**
* Update largest UID (all-time total emails processed)
* @param {number} uid - Largest UID from mailbox (0 if no emails)
*/
updateLargestUid(uid) { updateLargestUid(uid) {
if (uid >= 0 && uid > this.largestUid) { if (uid >= 0 && uid > this.largestUid) {
this.largestUid = uid this.largestUid = uid;
this._saveToDatabase() this._saveToDatabase();
debug(`Largest UID updated to ${uid}`) debug(`Largest UID updated to ${uid}`);
} }
} }
/**
* Record an email received event
*/
recordReceive() { recordReceive() {
this.currentCount++ this.currentCount++;
this._addDataPoint('receive') this._addDataPoint('receive');
debug(`Email received. Current: ${this.currentCount}`) debug(`Email received. Current: ${this.currentCount}`);
}
recordDelete() {
this.currentCount = Math.max(0, this.currentCount - 1);
this._addDataPoint('delete');
debug(`Email deleted. Current: ${this.currentCount}`);
}
recordForward() {
this._addDataPoint('forward');
debug(`Email forwarded`);
}
updateCurrentCount(count) {
const diff = count - this.currentCount;
if (diff < 0) {
for (let i = 0; i < Math.abs(diff); i++) {
this._addDataPoint('delete');
}
}
this.currentCount = count;
debug(`Current count updated to ${count}`);
}
getStats() {
this._cleanup();
const purgeWindowStats = this._getPurgeWindowStats();
return {
currentCount: this.currentCount,
allTimeTotal: this.largestUid,
purgeWindow: {
receives: purgeWindowStats.receives,
deletes: purgeWindowStats.deletes,
forwards: purgeWindowStats.forwards,
timeline: this._getTimeline()
}
};
}
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`);
const senderDomains = new Map();
const recipientDomains = new Map();
const hourlyActivity = Array(24).fill(0);
let totalSubjectLength = 0;
let subjectCount = 0;
let dayTimeEmails = 0;
let nightTimeEmails = 0;
allMails.forEach(mail => {
try {
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);
}
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);
}
if (mail.date) {
const date = new Date(mail.date);
if (!isNaN(date.getTime())) {
const hour = date.getHours();
hourlyActivity[hour]++;
if (hour >= 6 && hour < 18) dayTimeEmails++;
else nightTimeEmails++;
}
}
if (mail.subject) {
totalSubjectLength += mail.subject.length;
subjectCount++;
}
} catch (e) {}
});
const topSenderDomains = Array.from(senderDomains.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
const topRecipientDomains = Array.from(recipientDomains.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
const busiestHours = hourlyActivity.map((count, hour) => ({ hour, count })).filter(h => h.count > 0).sort((a, b) => b.count - a.count).slice(0, 5);
const peakHourCount = busiestHours.length > 0 ? busiestHours[0].count : 0;
const peakHourPercentage = allMails.length > 0 ? Math.round((peakHourCount / allMails.length) * 100) : 0;
const activeHours = hourlyActivity.filter(count => count > 0).length;
const emailsPerHour = activeHours > 0 ? Math.round(allMails.length / activeHours) : 0;
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,
dayPercentage
};
this.lastEnhancedStatsTime = now;
debug(`Enhanced stats calculated: ${this.enhancedStats.uniqueSenderDomains} unique sender domains, ${this.enhancedStats.busiestHours.length} busy hours`);
}
analyzeHistoricalData(allMails) {
if (!allMails || allMails.length === 0) {
debug('No historical data to analyze');
return;
}
const now = Date.now();
if (this.historicalData && (now - this.lastAnalysisTime) < this.analysisCacheDuration) {
debug(`Using cached historical data (${this.historicalData.length} points, age: ${Math.round((now - this.lastAnalysisTime) / 1000)}s)`);
return;
}
debug(`Analyzing ${allMails.length} emails for historical statistics`);
const startTime = Date.now();
const histogram = new Map();
allMails.forEach(mail => {
try {
const date = new Date(mail.date);
if (isNaN(date.getTime())) return;
const minute = Math.floor(date.getTime() / 60000) * 60000;
if (!histogram.has(minute)) histogram.set(minute, 0);
histogram.set(minute, histogram.get(minute) + 1);
} catch (e) {}
});
this.historicalData = Array.from(histogram.entries()).map(([timestamp, count]) => ({ timestamp, receives: count })).sort((a, b) => a.timestamp - b.timestamp);
this.lastAnalysisTime = now;
const elapsed = Date.now() - startTime;
debug(`Built historical data: ${this.historicalData.length} time buckets in ${elapsed}ms`);
}
getEnhancedStats() {
this._cleanup();
const purgeWindowStats = this._getPurgeWindowStats();
const timeline = this._getTimeline();
const historicalTimeline = this._getHistoricalTimeline();
const prediction = this._generatePrediction();
const cutoff = Date.now() - this._getPurgeCutoffMs();
const historicalReceives = historicalTimeline.filter(point => point.timestamp >= cutoff).reduce((sum, point) => sum + point.receives, 0);
return {
currentCount: this.currentCount,
allTimeTotal: this.largestUid,
purgeWindow: {
receives: purgeWindowStats.receives + historicalReceives,
deletes: purgeWindowStats.deletes,
forwards: purgeWindowStats.forwards,
timeline: timeline
},
historical: historicalTimeline,
prediction: prediction,
enhanced: this.enhancedStats
};
}
getLightweightStats() {
this._cleanup();
const purgeWindowStats = this._getPurgeWindowStats();
const timeline = this._getTimeline();
return {
currentCount: this.currentCount,
allTimeTotal: this.largestUid,
purgeWindow: {
receives: purgeWindowStats.receives,
deletes: purgeWindowStats.deletes,
forwards: purgeWindowStats.forwards,
timeline: timeline
}
};
}
_getPurgeWindowStats() {
const cutoff = Date.now() - this._getPurgeCutoffMs();
const recent = this.hourlyData.filter(e => e.timestamp >= cutoff);
return {
receives: recent.reduce((sum, e) => sum + e.receives, 0),
deletes: recent.reduce((sum, e) => sum + e.deletes, 0),
forwards: recent.reduce((sum, e) => sum + e.forwards, 0)
};
}
_getTimeline() {
const now = Date.now();
const cutoff = now - this._getPurgeCutoffMs();
const buckets = {};
this.hourlyData.filter(e => e.timestamp >= cutoff).forEach(entry => {
const interval = Math.floor(entry.timestamp / 900000) * 900000; // 15 minutes
if (!buckets[interval]) {
buckets[interval] = { timestamp: interval, receives: 0, deletes: 0, forwards: 0 };
}
buckets[interval].receives += entry.receives;
buckets[interval].deletes += entry.deletes;
buckets[interval].forwards += entry.forwards;
});
return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp);
}
_getHistoricalTimeline() {
if (!this.historicalData || this.historicalData.length === 0) {
return [];
}
const cutoff = Date.now() - this._getPurgeCutoffMs();
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff);
const intervalBuckets = new Map();
relevantHistory.forEach(point => {
const interval = Math.floor(point.timestamp / 900000) * 900000; // 15 minutes
if (!intervalBuckets.has(interval)) {
intervalBuckets.set(interval, 0);
}
intervalBuckets.set(interval, intervalBuckets.get(interval) + point.receives);
});
const intervalData = Array.from(intervalBuckets.entries()).map(([timestamp, receives]) => ({ timestamp, receives })).sort((a, b) => a.timestamp - b.timestamp);
debug(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`);
return intervalData;
}
_generatePrediction() {
if (!this.historicalData || this.historicalData.length < 100) {
return [];
}
const now = Date.now();
const predictions = [];
const hourlyPatterns = new Map();
this.historicalData.forEach(point => {
const date = new Date(point.timestamp);
const hour = date.getHours();
if (!hourlyPatterns.has(hour)) {
hourlyPatterns.set(hour, []);
}
hourlyPatterns.get(hour).push(point.receives);
});
const hourlyAverages = new Map();
hourlyPatterns.forEach((values, hour) => {
const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
hourlyAverages.set(hour, avg);
});
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`);
const purgeMs = this._getPurgeCutoffMs();
const purgeDurationHours = Math.ceil(purgeMs / (60 * 60 * 1000));
const predictionHours = Math.min(12, Math.ceil(purgeDurationHours * 0.2));
const predictionIntervals = predictionHours * 4;
for (let i = 1; i <= predictionIntervals; i++) {
const timestamp = now + (i * 15 * 60 * 1000);
const futureDate = new Date(timestamp);
const futureHour = futureDate.getHours();
let baseCount = hourlyAverages.get(futureHour);
if (baseCount === undefined) {
const allValues = Array.from(hourlyAverages.values());
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length;
}
const scaledCount = baseCount * 15;
const randomFactor = 0.8 + (Math.random() * 0.4);
const predictedCount = Math.round(scaledCount * randomFactor);
predictions.push({
timestamp,
receives: Math.max(0, predictedCount)
});
}
debug(`Generated ${predictions.length} prediction points based on hourly patterns`);
return predictions;
}
_addDataPoint(type) {
const now = Date.now();
const minute = Math.floor(now / 60000) * 60000;
let entry = this.hourlyData.find(e => e.timestamp === minute);
if (!entry) {
entry = { timestamp: minute, receives: 0, deletes: 0, forwards: 0 };
this.hourlyData.push(entry);
}
entry[type + 's']++;
this._cleanup();
if (Math.random() < 0.1) {
this._saveToDatabase();
}
}
_cleanup() {
const now = Date.now();
if (now - this.lastCleanup < 5 * 60 * 1000) {
return;
}
const cutoff = now - this._getPurgeCutoffMs();
const beforeCount = this.hourlyData.length;
this.hourlyData = this.hourlyData.filter(entry => entry.timestamp >= cutoff);
if (beforeCount !== this.hourlyData.length) {
this._saveToDatabase();
debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points (keeping data for ${config.email.purgeTime.time} ${config.email.purgeTime.unit})`);
}
this.lastCleanup = now;
} }
/** /**
@ -155,7 +405,9 @@ class StatisticsStore {
recordDelete() { recordDelete() {
this.currentCount = Math.max(0, this.currentCount - 1) this.currentCount = Math.max(0, this.currentCount - 1)
this._addDataPoint('delete') this._addDataPoint('delete')
debug(`Email deleted. Current: ${this.currentCount}`) debug(`
Email deleted.Current: $ { this.currentCount }
`)
} }
/** /**
@ -163,7 +415,8 @@ class StatisticsStore {
*/ */
recordForward() { recordForward() {
this._addDataPoint('forward') this._addDataPoint('forward')
debug(`Email forwarded`) debug(`
Email forwarded `)
} }
/** /**
@ -179,7 +432,8 @@ class StatisticsStore {
} }
} }
this.currentCount = count this.currentCount = count
debug(`Current count updated to ${count}`) debug(`
`)
} }
/** /**
@ -189,15 +443,15 @@ class StatisticsStore {
getStats() { getStats() {
this._cleanup() this._cleanup()
const last24h = this._getLast24Hours() const purgeWindowStats = this._getPurgeWindowStats()
return { return {
currentCount: this.currentCount, currentCount: this.currentCount,
allTimeTotal: this.largestUid, allTimeTotal: this.largestUid,
last24Hours: { purgeWindow: {
receives: last24h.receives, receives: purgeWindowStats.receives,
deletes: last24h.deletes, deletes: purgeWindowStats.deletes,
forwards: last24h.forwards, forwards: purgeWindowStats.forwards,
timeline: this._getTimeline() timeline: this._getTimeline()
} }
} }
@ -216,11 +470,16 @@ class StatisticsStore {
const now = Date.now() const now = Date.now()
if (this.enhancedStats && (now - this.lastEnhancedStatsTime) < this.enhancedStatsCacheDuration) { if (this.enhancedStats && (now - this.lastEnhancedStatsTime) < this.enhancedStatsCacheDuration) {
debug(`Using cached enhanced stats (age: ${Math.round((now - this.lastEnhancedStatsTime) / 1000)}s)`) debug(`
Using cached enhanced stats(age: $ { Math.round((now - this.lastEnhancedStatsTime) / 1000) }
s)
`)
return return
} }
debug(`Calculating enhanced statistics from ${allMails.length} emails`) debug(`
Calculating enhanced statistics from $ { allMails.length }
emails `)
// Track sender domains (privacy-friendly: domain only, not full address) // Track sender domains (privacy-friendly: domain only, not full address)
const senderDomains = new Map() const senderDomains = new Map()
@ -332,7 +591,10 @@ class StatisticsStore {
} }
this.lastEnhancedStatsTime = now this.lastEnhancedStatsTime = now
debug(`Enhanced stats calculated: ${this.enhancedStats.uniqueSenderDomains} unique sender domains, ${this.enhancedStats.busiestHours.length} busy hours`) debug(`
Enhanced stats calculated: $ { this.enhancedStats.uniqueSenderDomains }
unique sender domains, $ { this.enhancedStats.busiestHours.length }
busy hours `)
} }
/** /**
@ -348,11 +610,18 @@ class StatisticsStore {
// Check cache - if analysis was done recently, skip it // Check cache - if analysis was done recently, skip it
const now = Date.now() const now = Date.now()
if (this.historicalData && (now - this.lastAnalysisTime) < this.analysisCacheDuration) { if (this.historicalData && (now - this.lastAnalysisTime) < this.analysisCacheDuration) {
debug(`Using cached historical data (${this.historicalData.length} points, age: ${Math.round((now - this.lastAnalysisTime) / 1000)}s)`) debug(`
Using cached historical data($ { this.historicalData.length }
points, age: $ { Math.round((now - this.lastAnalysisTime) / 1000) }
s)
`)
return return
} }
debug(`Analyzing ${allMails.length} emails for historical statistics`) debug(`
Analyzing $ { allMails.length }
emails
for historical statistics `)
const startTime = Date.now() const startTime = Date.now()
// Group emails by minute // Group emails by minute
@ -382,7 +651,10 @@ class StatisticsStore {
this.lastAnalysisTime = now this.lastAnalysisTime = now
const elapsed = Date.now() - startTime const elapsed = Date.now() - startTime
debug(`Built historical data: ${this.historicalData.length} time buckets in ${elapsed}ms`) debug(`
Built historical data: $ { this.historicalData.length }
time buckets in $ { elapsed }
ms `)
} }
/** /**
@ -392,7 +664,7 @@ class StatisticsStore {
getEnhancedStats() { getEnhancedStats() {
this._cleanup() this._cleanup()
const last24h = this._getLast24Hours() const purgeWindowStats = this._getPurgeWindowStats()
const timeline = this._getTimeline() const timeline = this._getTimeline()
const historicalTimeline = this._getHistoricalTimeline() const historicalTimeline = this._getHistoricalTimeline()
const prediction = this._generatePrediction() const prediction = this._generatePrediction()
@ -406,10 +678,10 @@ class StatisticsStore {
return { return {
currentCount: this.currentCount, currentCount: this.currentCount,
allTimeTotal: this.largestUid, allTimeTotal: this.largestUid,
last24Hours: { purgeWindow: {
receives: last24h.receives + historicalReceives, receives: purgeWindowStats.receives + historicalReceives,
deletes: last24h.deletes, deletes: purgeWindowStats.deletes,
forwards: last24h.forwards, forwards: purgeWindowStats.forwards,
timeline: timeline timeline: timeline
}, },
historical: historicalTimeline, historical: historicalTimeline,
@ -425,16 +697,16 @@ class StatisticsStore {
getLightweightStats() { getLightweightStats() {
this._cleanup() this._cleanup()
const last24h = this._getLast24Hours() const purgeWindowStats = this._getPurgeWindowStats()
const timeline = this._getTimeline() const timeline = this._getTimeline()
return { return {
currentCount: this.currentCount, currentCount: this.currentCount,
allTimeTotal: this.largestUid, allTimeTotal: this.largestUid,
last24Hours: { purgeWindow: {
receives: last24h.receives, receives: purgeWindowStats.receives,
deletes: last24h.deletes, deletes: purgeWindowStats.deletes,
forwards: last24h.forwards, forwards: purgeWindowStats.forwards,
timeline: timeline timeline: timeline
} }
} }
@ -470,7 +742,11 @@ class StatisticsStore {
.map(([timestamp, receives]) => ({ timestamp, receives })) .map(([timestamp, receives]) => ({ timestamp, receives }))
.sort((a, b) => a.timestamp - b.timestamp) .sort((a, b) => a.timestamp - b.timestamp)
debug(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`) debug(`
Historical timeline: $ { intervalData.length }
15 - min interval points within $ { config.email.purgeTime.time }
$ { config.email.purgeTime.unit }
window `)
return intervalData return intervalData
} }
@ -510,7 +786,11 @@ class StatisticsStore {
hourlyAverages.set(hour, avg) hourlyAverages.set(hour, avg)
}) })
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`) debug(`
Built hourly patterns
for $ { hourlyAverages.size }
hours from $ { this.historicalData.length }
data points `)
// Generate predictions for a reasonable future window // Generate predictions for a reasonable future window
// Limit to 20% of purge duration or 12 hours max to maintain chart balance // Limit to 20% of purge duration or 12 hours max to maintain chart balance
@ -546,7 +826,9 @@ class StatisticsStore {
}) })
} }
debug(`Generated ${predictions.length} prediction points based on hourly patterns`) debug(`
Generated $ { predictions.length }
prediction points based on hourly patterns `)
return predictions return predictions
} }
@ -599,7 +881,12 @@ class StatisticsStore {
if (beforeCount !== this.hourlyData.length) { if (beforeCount !== this.hourlyData.length) {
this._saveToDatabase() // Save after cleanup this._saveToDatabase() // Save after cleanup
debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points (keeping data for ${config.email.purgeTime.time} ${config.email.purgeTime.unit})`) debug(`
Cleaned up $ { beforeCount - this.hourlyData.length }
old data points(keeping data
for $ { config.email.purgeTime.time }
$ { config.email.purgeTime.unit })
`)
} }
this.lastCleanup = now this.lastCleanup = now
@ -610,16 +897,7 @@ class StatisticsStore {
* @returns {Object} Aggregated counts * @returns {Object} Aggregated counts
* @private * @private
*/ */
_getLast24Hours() {
const cutoff = Date.now() - this._getPurgeCutoffMs()
const recent = this.hourlyData.filter(e => e.timestamp >= cutoff)
return {
receives: recent.reduce((sum, e) => sum + e.receives, 0),
deletes: recent.reduce((sum, e) => sum + e.deletes, 0),
forwards: recent.reduce((sum, e) => sum + e.forwards, 0)
}
}
/** /**
* Get timeline data for graphing (hourly aggregates) * Get timeline data for graphing (hourly aggregates)

View file

@ -10,12 +10,12 @@ Endpoints for retrieving statistics and historical data.
### GET `/api/v1/stats/` ### GET `/api/v1/stats/`
Get lightweight statistics (no historical analysis). Get lightweight statistics (no historical analysis).
- **Response:** - **Response:**
- `currentCount`, `allTimeTotal`, `last24Hours` (object with `receives`, `deletes`, `forwards`, `timeline`) - `currentCount`, `allTimeTotal`, `purgeWindow` (object with `receives`, `deletes`, `forwards`, `timeline`)
### GET `/api/v1/stats/enhanced` ### GET `/api/v1/stats/enhanced`
Get full statistics with historical data and predictions. Get full statistics with historical data and predictions.
- **Response:** - **Response:**
- `currentCount`, `allTimeTotal`, `last24Hours`, `historical`, `prediction`, `enhanced` - `currentCount`, `allTimeTotal`, `purgeWindow`, `historical`, `prediction`, `enhanced`
--- ---
@ -41,7 +41,7 @@ Get full statistics with historical data and predictions.
"data": { "data": {
"currentCount": 123, "currentCount": 123,
"allTimeTotal": 4567, "allTimeTotal": 4567,
"last24Hours": { "purgeWindow": {
"receives": 10, "receives": 10,
"deletes": 2, "deletes": 2,
"forwards": 1, "forwards": 1,

View file

@ -341,11 +341,16 @@ function reloadStatsData() {
*/ */
function updateStatsDOM(data) { function updateStatsDOM(data) {
// Update main stat cards // Update main stat cards
document.getElementById('currentCount').textContent = data.currentCount || '0'; const elCurrent = document.getElementById('currentCount');
document.getElementById('historicalTotal').textContent = data.allTimeTotal || '0'; if (elCurrent) elCurrent.textContent = data.currentCount || '0';
document.getElementById('receives24h').textContent = (data.last24Hours && data.last24Hours.receives) || '0'; const elTotal = document.getElementById('historicalTotal');
document.getElementById('deletes24h').textContent = (data.last24Hours && data.last24Hours.deletes) || '0'; if (elTotal) elTotal.textContent = data.allTimeTotal || '0';
document.getElementById('forwards24h').textContent = (data.last24Hours && data.last24Hours.forwards) || '0'; const elReceives = document.getElementById('receivesPurgeWindow');
if (elReceives) elReceives.textContent = (data.purgeWindow && data.purgeWindow.receives) || '0';
const elDeletes = document.getElementById('deletesPurgeWindow');
if (elDeletes) elDeletes.textContent = (data.purgeWindow && data.purgeWindow.deletes) || '0';
const elForwards = document.getElementById('forwardsPurgeWindow');
if (elForwards) elForwards.textContent = (data.purgeWindow && data.purgeWindow.forwards) || '0';
// Update enhanced stats if available // Update enhanced stats if available
if (data.enhanced) { if (data.enhanced) {
@ -420,7 +425,7 @@ function updateStatsDOM(data) {
} }
// Update window data for charts // Update window data for charts
window.initialStatsData = (data.last24Hours && data.last24Hours.timeline) || []; window.initialStatsData = (data.purgeWindow && data.purgeWindow.timeline) || [];
window.historicalData = data.historical || []; window.historicalData = data.historical || [];
window.predictionData = data.prediction || []; window.predictionData = data.prediction || [];

View file

@ -52,7 +52,7 @@ router.get('/', async(req, res) => {
const placeholderStats = { const placeholderStats = {
currentCount: '...', currentCount: '...',
allTimeTotal: '...', allTimeTotal: '...',
last24Hours: { purgeWindow: {
receives: '...', receives: '...',
deletes: '...', deletes: '...',
forwards: '...', forwards: '...',

View file

@ -71,19 +71,19 @@
<!-- Receives (Purge Window) --> <!-- Receives (Purge Window) -->
<div class="stat-card"> <div class="stat-card">
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div> <div class="stat-value" id="receivesPurgeWindow">{{ stats.purgeWindow.receives }}</div>
<div class="stat-label">Received</div> <div class="stat-label">Received</div>
</div> </div>
<!-- Deletes (Purge Window) --> <!-- Deletes (Purge Window) -->
<div class="stat-card"> <div class="stat-card">
<div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div> <div class="stat-value" id="deletesPurgeWindow">{{ stats.purgeWindow.deletes }}</div>
<div class="stat-label">Deleted</div> <div class="stat-label">Deleted</div>
</div> </div>
<!-- Forwards (Purge Window) --> <!-- Forwards (Purge Window) -->
<div class="stat-card"> <div class="stat-card">
<div class="stat-value" id="forwards24h">{{ stats.last24Hours.forwards }}</div> <div class="stat-value" id="forwardsPurgeWindow">{{ stats.purgeWindow.forwards }}</div>
<div class="stat-label">Forwarded</div> <div class="stat-label">Forwarded</div>
</div> </div>
</div> </div>
@ -194,7 +194,7 @@
<script> <script>
// Set initial data for stats.js to consume // Set initial data for stats.js to consume
window.initialStatsData = {{ stats.last24Hours.timeline|json_encode|raw }}; window.initialStatsData = {{ stats.purgeWindow.timeline|json_encode|raw }};
window.historicalData = {{ stats.historical|json_encode|raw }}; window.historicalData = {{ stats.historical|json_encode|raw }};
window.predictionData = {{ stats.prediction|json_encode|raw }}; window.predictionData = {{ stats.prediction|json_encode|raw }};
</script> </script>