dragon-ui-claude
Version:
🐲 Ultra-fast, cross-platform Claude Code Max usage dashboard with dragon-inspired design, advanced background services, and multi-currency support
993 lines (860 loc) • 40.4 kB
JavaScript
/**
* Core Data Worker
* Runs heavy calculations in a separate thread to avoid blocking the UI
* Now powered by SQLite for blazing fast calculations
*/
const { parentPort, workerData } = require('worker_threads');
const path = require('path');
const DatabaseService = require('./database.cjs');
const { modelPriceService } = require('./model-price-service.cjs');
// Import calculation logic (we'll move the heavy parts here)
class CoreDataWorker {
constructor() {
this.currency = 'USD';
this.exchangeRates = {};
this.db = new DatabaseService();
// Ensure cache token columns exist in worker thread
try {
this.db.db.exec(`ALTER TABLE usage_entries ADD COLUMN cache_creation_input_tokens INTEGER DEFAULT 0`);
} catch (e) {
// Column already exists, ignore
}
try {
this.db.db.exec(`ALTER TABLE usage_entries ADD COLUMN cache_read_input_tokens INTEGER DEFAULT 0`);
} catch (e) {
// Column already exists, ignore
}
}
// Extract main project name from full path (group subfolders)
extractProjectName(projectPath) {
if (!projectPath) return null;
// If it's already just a name, return it
if (!projectPath.includes('/') && !projectPath.includes('\\')) {
return projectPath;
}
// Normalize path separators
const normalizedPath = projectPath.replace(/\\/g, '/');
const pathParts = normalizedPath.split('/');
// Find the main project directory (usually under /Coding/ or similar)
const codingIndex = pathParts.findIndex(part =>
part.toLowerCase().includes('coding') ||
part.toLowerCase().includes('projects') ||
part.toLowerCase().includes('dev') ||
part.toLowerCase().includes('work')
);
if (codingIndex >= 0 && codingIndex < pathParts.length - 1) {
// Return the directory immediately after the coding folder
return pathParts[codingIndex + 1];
}
// Fallback: if path has at least 2 parts, take the second-to-last
// This handles cases like /some/main-project/subfolder
if (pathParts.length >= 2) {
return pathParts[pathParts.length - 2];
}
// Final fallback
return path.basename(projectPath);
}
convertCurrency(usdAmount) {
if (this.currency === 'USD') return usdAmount;
const rate = this.exchangeRates[this.currency]?.rate || 1;
const converted = usdAmount * rate;
// Debug extreme values
if (converted > usdAmount * 100) {
console.error(`[EXTREME CURRENCY] EXTREME CURRENCY CONVERSION: $${usdAmount} -> $${converted} (rate: ${rate}, currency: ${this.currency})`);
return usdAmount; // Fallback to USD to prevent impossible values
}
return converted;
}
async calculateAllData(usageEntries, currency = 'USD', exchangeRates = {}, billingCycleDay = 1) {
const startTime = performance.now();
this.currency = currency;
this.exchangeRates = exchangeRates;
console.log(`[WORKER] Using SQLite database for blazing fast calculations`);
console.log(`[MONEY] Currency: ${currency}, Exchange rates:`, exchangeRates);
// Use database aggregations instead of processing arrays
const dbInfo = this.db.getDbInfo();
console.log(`[DB] Worker: DB contains ${dbInfo.entryCount} entries (${dbInfo.dbSizeMB}MB)`);
console.log(`[BILLING] Worker: Using billing cycle day ${billingCycleDay}`);
// Quick DB-powered calculations - USE BILLING CYCLE GROUPING
const sessionStats = this.db.getSessionStats();
const projectStats = this.db.getProjectStats();
const monthlyStats = this.db.getMonthlyStats(billingCycleDay); // Use billing cycle
const dailyStats = this.db.getDailyStats(7); // Use pure calendar day grouping
const dailyFinancialStats = this.db.getDailyFinancialStats(30); // Last 30 days for chart
// Lightning-fast DB calculations
const totalCost = this.convertCurrency(this.db.getTotalCost());
const totalTokens = this.db.getTotalTokens();
const result = {
// Basic Financial (from DB aggregations)
totalCost: totalCost,
cost: totalCost,
currentCost: 0, // Will be calculated for active session
costAmount: totalCost,
// Sessions & Projects (from DB aggregations) - placeholder, will be updated later
totalSessions: sessionStats.length,
sessions: sessionStats.length,
sessionsCount: sessionStats.length,
validSessions: sessionStats.length,
recentSessions: 0, // Will be calculated
totalProjects: 0, // Will be calculated after project grouping
projectsCount: 0, // Will be calculated after project grouping
// Token data (from DB aggregations)
totalTokens: totalTokens,
averageCostPerSession: sessionStats.length > 0 ? totalCost / sessionStats.length : 0,
// Pre-calculated data arrays
sessionsData: [],
projectsData: [],
dailyData: [],
monthlyData: [],
dailyFinancialData: [],
// Additional fields
currentMonth: null,
currentMonthCost: 0,
averageMonthlySpend: 0,
highestSpendingMonth: null,
mostActiveMonth: null,
monthlyGrowth: null,
projectedYearlySpend: 0,
quarterlyProjection: 0,
currentRunRate: 0,
// Overview specific
last7DaysTotal: 0,
activityData: [],
// Session metadata
models: [],
modelsCount: 0,
modelsList: [],
mostActiveProject: null,
activeDays: 0,
avgTokensPerSession: 0,
costPer1MTokens: 0,
costPerToken: 0,
costPer1KTokens: 0,
costPerConversation: 0,
costPerEntry: 0,
// Status & Timing
status: 'idle',
sessionStatus: 'idle',
activeSession: null,
sessionActive: false,
sessionId: null,
started: null,
duration: 0,
timeLeft: 0,
timeAgo: null,
dateTime: null,
sessionTimeLeft: 0,
lastActivity: null
};
console.log(`[STATS] DB Stats: $${totalCost.toFixed(2)}, ${totalTokens.toLocaleString()} tokens, ${sessionStats.length} sessions`);
// Get models for each session from database
const sessionModelsQuery = this.db.db.prepare(`
SELECT session_id, GROUP_CONCAT(DISTINCT model) as models
FROM usage_entries
WHERE session_id IS NOT NULL AND model IS NOT NULL
GROUP BY session_id
`);
const sessionModelsData = sessionModelsQuery.all();
// Create a map of session_id to models
const sessionModelsMap = new Map();
sessionModelsData.forEach(row => {
const models = row.models ? row.models.split(',') : [];
sessionModelsMap.set(row.session_id, models);
});
// Process sessions data from DB (lightning fast!)
result.sessionsData = sessionStats
.map(session => {
const sessionCost = this.convertCurrency(session.total_cost);
const duration = session.start_time && session.end_time ?
Math.floor((new Date(session.end_time) - new Date(session.start_time)) / (1000 * 60)) : null;
// Cap duration at 300 minutes (5 hours) for display
const cappedDuration = duration ? Math.min(duration, 300) : null;
return {
sessionId: session.session_id,
totalCost: sessionCost,
totalTokens: session.total_tokens,
conversations: session.entry_count,
project: this.extractProjectName(session.project), // Extract clean project name
isActive: false, // Will be determined later
startTime: session.start_time,
endTime: session.end_time,
duration: cappedDuration,
models: sessionModelsMap.get(session.session_id.split('_')[0]) || [] // Handle segmented session IDs
};
})
.filter(session => {
// Filter out sessions with N/A duration or under 10 minutes
return session.duration !== null && session.duration >= 10;
});
// Update session counts after filtering
result.totalSessions = result.sessionsData.length;
result.sessions = result.sessionsData.length;
result.sessionsCount = result.sessionsData.length;
result.validSessions = result.sessionsData.length;
// Process projects data from DB (lightning fast!)
// First extract project names, then group by the extracted names
const projectMap = new Map();
const sessionProjectMap = new Map(); // Track unique sessions per project
projectStats.forEach(project => {
const extractedName = this.extractProjectName(project.project);
if (!extractedName) return;
if (projectMap.has(extractedName)) {
// Merge with existing project
const existing = projectMap.get(extractedName);
existing.totalCost += this.convertCurrency(project.total_cost);
existing.totalTokens += project.total_tokens;
// Don't add sessions here - we'll count unique sessions separately
// Keep the most recent activity
if (project.last_activity > existing.lastActivity) {
existing.lastActivity = project.last_activity;
}
} else {
// Create new project entry
projectMap.set(extractedName, {
project: extractedName,
totalCost: this.convertCurrency(project.total_cost),
totalTokens: project.total_tokens,
sessions: 0, // Will be calculated from sessionProjectMap
lastActivity: project.last_activity,
models: [] // TODO: Add model info if needed
});
sessionProjectMap.set(extractedName, new Set());
}
});
// Count unique sessions per extracted project name
result.sessionsData.forEach(session => {
const projectName = session.project; // Already extracted in sessionsData
if (projectName && sessionProjectMap.has(projectName)) {
sessionProjectMap.get(projectName).add(session.sessionId);
}
});
// Update session counts with unique sessions
sessionProjectMap.forEach((sessionSet, projectName) => {
if (projectMap.has(projectName)) {
projectMap.get(projectName).sessions = sessionSet.size;
}
});
// Get models for each project from database
const projectModelsQuery = this.db.db.prepare(`
SELECT project, GROUP_CONCAT(DISTINCT model) as models
FROM usage_entries
WHERE project IS NOT NULL AND model IS NOT NULL
GROUP BY project
`);
const projectModelsData = projectModelsQuery.all();
// Create a map of extracted project names to their models
const projectModelsMap = new Map();
projectModelsData.forEach(row => {
const extractedName = this.extractProjectName(row.project);
if (extractedName) {
const models = row.models ? row.models.split(',') : [];
if (projectModelsMap.has(extractedName)) {
// Merge models for the same extracted project name
const existing = projectModelsMap.get(extractedName);
const allModels = [...existing, ...models];
projectModelsMap.set(extractedName, [...new Set(allModels)]); // Remove duplicates
} else {
projectModelsMap.set(extractedName, models);
}
}
});
// Update project data with models
projectMap.forEach((project, projectName) => {
project.models = projectModelsMap.get(projectName) || [];
});
// Convert map to array and calculate averages
result.projectsData = Array.from(projectMap.values()).map(project => ({
...project,
avgCostPerSession: project.sessions > 0 ? project.totalCost / project.sessions : 0
})).sort((a, b) => b.totalCost - a.totalCost); // Sort by total cost descending
// Update project counts after grouping
result.totalProjects = result.projectsData.length;
result.projectsCount = result.projectsData.length;
// Calculate averages and additional fields
result.avgTokensPerSession = result.totalSessions > 0 ? result.totalTokens / result.totalSessions : 0;
result.costPer1MTokens = result.totalTokens > 0 ? (result.totalCost / result.totalTokens) * 1000000 : 0;
result.costPerToken = result.totalTokens > 0 ? result.totalCost / result.totalTokens : 0;
result.costPer1KTokens = result.totalTokens > 0 ? (result.totalCost / result.totalTokens) * 1000 : 0;
// Get models from database
const modelsQuery = this.db.db.prepare('SELECT DISTINCT model FROM usage_entries WHERE model IS NOT NULL').all();
result.models = modelsQuery.map(row => row.model);
result.modelsCount = result.models.length;
result.modelsList = result.models;
// Calculate active days - use current billing period for consistency
const currentPeriodData = this.db.getCurrentBillingPeriodData(billingCycleDay);
result.activeDays = currentPeriodData?.activeDays || 0;
// Calculate total days tracked (from first entry to today)
const firstEntryQuery = this.db.db.prepare('SELECT MIN(date(timestamp)) as first_date FROM usage_entries').get();
let daysTracked = 0;
if (firstEntryQuery?.first_date) {
const firstDate = new Date(firstEntryQuery.first_date);
const today = new Date();
const timeDiff = today.getTime() - firstDate.getTime();
daysTracked = Math.ceil(timeDiff / (1000 * 3600 * 24)) + 1; // +1 to include both start and end days
}
result.daysTracked = daysTracked;
// Process billing period data with enhanced calculations
const avgPeriodCost = monthlyStats.length > 1 ?
monthlyStats.reduce((sum, m) => sum + this.convertCurrency(m.total_cost), 0) / monthlyStats.length : 0;
// Get current billing period info
const currentPeriod = this.db.getBillingPeriodForDate(new Date(), billingCycleDay);
const currentPeriodKey = currentPeriod.key;
// Don't add empty current periods - only show periods with actual data
// This prevents confusing empty "Current" entries in the UI
console.log(`[MONTH] Monthly stats from DB:`, monthlyStats.map(m => ({ month: m.month, cost: m.total_cost, tokens: m.total_tokens })));
result.monthlyData = monthlyStats
.filter(period => {
// Filter out invalid months before processing
if (!period.month || typeof period.month !== 'string') {
console.log(`[MONTH] ❌ Filtering out invalid month: ${period.month}`);
return false;
}
// For calendar months (billingCycleDay === 1), validate YYYY-MM format
if (billingCycleDay === 1 && period.month.includes('-')) {
const [year, monthNum] = period.month.split('-').map(Number);
if (isNaN(year) || isNaN(monthNum) || year < 2020 || monthNum < 1 || monthNum > 12) {
console.log(`[MONTH] ❌ Filtering out corrupted calendar month: ${period.month} (year: ${year}, month: ${monthNum})`);
return false;
}
}
// For custom billing periods, check if the month key contains a valid year
if (billingCycleDay !== 1) {
const yearMatch = period.month.match(/(\d{4})/);
if (!yearMatch || parseInt(yearMatch[1]) < 2020) {
console.log(`[MONTH] ❌ Filtering out corrupted billing period: ${period.month}`);
return false;
}
}
console.log(`[MONTH] ✅ Valid period: ${period.month} ($${period.total_cost}, ${period.total_tokens} tokens)`);
return true;
})
.map(period => {
const periodCost = this.convertCurrency(period.total_cost);
const costPer1MTokens = period.total_tokens > 0 ?
(periodCost / period.total_tokens) * 1000000 : 0;
// Calculate total days in the billing period
let totalDaysInPeriod = 30; // Default fallback
if (period.billing_period_start && period.billing_period_end) {
const start = new Date(period.billing_period_start);
const end = new Date(period.billing_period_end);
totalDaysInPeriod = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1;
} else if (billingCycleDay === 1) {
// For calendar months, calculate normally (with validation)
if (period.month && typeof period.month === 'string' && period.month.includes('-')) {
const [year, monthNum] = period.month.split('-').map(Number);
if (!isNaN(year) && !isNaN(monthNum) && year >= 2020 && monthNum >= 1 && monthNum <= 12) {
totalDaysInPeriod = new Date(year, monthNum, 0).getDate();
} else {
console.log(`[MONTH] Invalid month format detected: ${period.month}, using default 30 days`);
totalDaysInPeriod = 30;
}
} else {
console.log(`[MONTH] Invalid month string detected: ${period.month}, using default 30 days`);
totalDaysInPeriod = 30;
}
}
// Only calculate vs avg if we have more than 1 period
let vsAveragePercent = 0;
if (monthlyStats.length > 1 && avgPeriodCost > 0) {
vsAveragePercent = ((periodCost - avgPeriodCost) / avgPeriodCost * 100);
}
return {
date: period.month,
billing_period_start: period.billing_period_start,
billing_period_end: period.billing_period_end,
billing_period_label: period.billing_period_label || period.month,
totalCost: periodCost,
totalTokens: period.total_tokens,
totalSessions: period.session_count,
activeDays: period.active_days,
totalDays: totalDaysInPeriod,
dailyAverage: period.active_days > 0 ? periodCost / period.active_days : 0,
avgSessionCost: period.session_count > 0 ? periodCost / period.session_count : 0,
tokensPerSession: period.session_count > 0 ? period.total_tokens / period.session_count : 0,
isCurrentMonth: period.month === currentPeriodKey,
isCurrentPeriod: period.month === currentPeriodKey,
costPer1MTokens: Math.round(costPer1MTokens * 100) / 100,
vsAveragePercent: Math.round(vsAveragePercent * 10) / 10,
isFirstMonth: monthlyStats.length === 1,
billingCycleDay: billingCycleDay
};
});
// Process daily activity data (already in chronological order from DB)
result.activityData = dailyStats.map(day => ({
date: day.date,
cost: this.convertCurrency(day.total_cost),
tokens: day.total_tokens,
sessions: day.session_count,
label: new Date(day.date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})
}));
// Active session detection using DB
const now = Date.now();
const recentEntries = this.db.getRecentEntries(30); // Last 30 minutes
console.log(`[ACTIVE DEBUG] Found ${recentEntries.length} entries in last 30 minutes`);
if (recentEntries.length > 0) {
const latest = recentEntries[0];
const minutesAgo = Math.round((now - new Date(latest.timestamp).getTime()) / (1000 * 60));
console.log(`[ACTIVE DEBUG] Latest entry: ${latest.session_id}, ${minutesAgo}min ago`);
}
let activeSessionId = null;
if (recentEntries.length > 0) {
// Find the most recent session
const latestEntry = recentEntries.reduce((latest, entry) => {
return new Date(entry.timestamp) > new Date(latest.timestamp) ? entry : latest;
});
activeSessionId = latestEntry.session_id;
}
// Set active session data
result.activeSession = activeSessionId;
result.sessionActive = !!activeSessionId;
result.sessionId = activeSessionId;
// Calculate current session data if active
if (activeSessionId) {
const sessionEntries = this.db.getSessionEntries(activeSessionId);
if (sessionEntries.length > 0) {
const sessionStart = new Date(Math.min(...sessionEntries.map(e => new Date(e.timestamp).getTime())));
const sessionEnd = new Date(Math.max(...sessionEntries.map(e => new Date(e.timestamp).getTime())));
const sessionCost = sessionEntries.reduce((sum, e) => sum + this.convertCurrency(e.cost || 0), 0);
const sessionTokens = sessionEntries.reduce((sum, e) => sum + ((e.input_tokens || 0) + (e.output_tokens || 0) + (e.cache_creation_input_tokens || 0) + (e.cache_read_input_tokens || 0)), 0);
const durationMinutes = Math.floor((sessionEnd.getTime() - sessionStart.getTime()) / (1000 * 60));
const timeLeftMinutes = Math.max(0, 300 - durationMinutes); // 5 hours = 300 minutes
console.log(`[DEBUG] Current Session Debug: ${sessionEntries.length} entries, cost=$${sessionCost.toFixed(4)}, tokens=${sessionTokens}, duration=${durationMinutes}min`);
result.started = sessionStart.toISOString();
result.duration = durationMinutes;
result.currentCost = sessionCost;
result.currentTokens = sessionTokens; // Add current session tokens
result.timeLeft = timeLeftMinutes;
result.sessionTimeLeft = timeLeftMinutes;
result.lastActivity = sessionEnd.toISOString();
result.status = timeLeftMinutes > 0 ? 'active' : 'expired';
result.sessionStatus = result.status;
console.log(`[DEBUG] Active Session Calculated: Cost=$${sessionCost.toFixed(2)}, Tokens=${sessionTokens}, Duration=${durationMinutes}min`);
}
} else {
result.status = 'idle';
result.sessionStatus = 'idle';
result.currentCost = 0; // No active session = no current cost
result.currentTokens = 0; // No active session = no current tokens
result.duration = 0;
result.timeLeft = 0;
result.sessionTimeLeft = 0;
console.log(`[DEBUG] No Active Session: currentCost set to $0.00`);
}
// Mark active session in sessionsData
result.sessionsData = result.sessionsData.map(session => ({
...session,
isActive: session.sessionId === activeSessionId
}));
// Find most active project and set additional stats
if (result.projectsData.length > 0) {
const mostActive = result.projectsData.reduce((max, proj) =>
proj.totalCost > max.totalCost ? proj : max
);
result.mostActiveProject = mostActive.project;
}
// Calculate 7-day total for overview
const last7Days = dailyStats.reduce((sum, day) => sum + day.total_cost, 0);
result.last7DaysTotal = this.convertCurrency(last7Days);
// Daily usage specific data - USE ENTRY-DATE GROUPING for today/yesterday
const todayData = this.db.getTodayData();
const yesterdayData = this.db.getYesterdayData();
const lastSessionData = this.db.getLastSessionData();
// Add daily data to result
result.todayData = todayData ? {
date: todayData.date,
totalCost: this.convertCurrency(todayData.totalCost),
totalTokens: todayData.totalTokens,
sessionCount: todayData.sessionCount,
modelCount: todayData.modelCount,
models: todayData.models,
entryCount: todayData.entryCount,
firstActivity: todayData.firstActivity,
lastActivity: todayData.lastActivity
} : null;
result.yesterdayData = yesterdayData ? {
date: yesterdayData.date,
totalCost: this.convertCurrency(yesterdayData.totalCost),
totalTokens: yesterdayData.totalTokens,
sessionCount: yesterdayData.sessionCount,
modelCount: yesterdayData.modelCount,
models: yesterdayData.models,
entryCount: yesterdayData.entryCount,
firstActivity: yesterdayData.firstActivity,
lastActivity: yesterdayData.lastActivity
} : null;
result.lastSessionData = lastSessionData ? {
sessionId: lastSessionData.sessionId,
totalCost: this.convertCurrency(lastSessionData.totalCost),
totalTokens: lastSessionData.totalTokens,
entryCount: lastSessionData.entryCount,
startTime: lastSessionData.startTime,
endTime: lastSessionData.endTime,
duration: lastSessionData.duration,
project: this.extractProjectName(lastSessionData.project), // Extract clean project name
lastActivity: lastSessionData.lastActivity
} : null;
// Enhanced daily data array for charts and history with session-start-date grouping
result.dailyData = dailyStats.map(day => {
return {
date: day.date,
totalCost: this.convertCurrency(day.total_cost),
totalTokens: day.total_tokens,
sessionCount: day.session_count,
sessions: day.session_count, // Add sessions field for frontend compatibility
models: day.models || [],
modelCount: day.model_count || 0,
costPer1KTokens: day.total_tokens > 0 ?
parseFloat(((day.total_cost / day.total_tokens) * 1000).toFixed(6)) : 0
};
});
// Daily financial data for line chart (30 days) with running total
result.dailyFinancialData = dailyFinancialStats.map(day => {
const cost = this.convertCurrency(day.total_cost);
const runningTotal = this.convertCurrency(day.running_total);
return {
date: day.date,
totalCost: cost,
runningTotal: runningTotal,
sessionCount: day.session_count || 0,
entryCount: day.entry_count || 0,
firstActivity: day.first_activity,
lastActivity: day.last_activity,
// Format for chart display
datetime: new Date(day.date).toISOString(),
formattedDate: new Date(day.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
}),
money: cost,
cumulativeMoney: runningTotal
};
});
console.log(`[CHART] Daily financial data: ${result.dailyFinancialData.length} days, total: $${result.dailyFinancialData.reduce((sum, d) => sum + d.money, 0).toFixed(2)}`);
// Average daily cost calculation
result.avgDailyCost = result.activeDays > 0 ? result.totalCost / result.activeDays : 0;
// Monthly summary calculations
if (result.monthlyData.length > 0) {
// Find current month data - use the most recent period with data
const mostRecentPeriod = result.monthlyData[0]; // monthlyData is sorted newest first
// For now, treat the most recent period as current if it has data
let currentMonthData = null;
// Try to find explicitly marked current month first
currentMonthData = result.monthlyData.find(m => m.isCurrentMonth);
// If no explicitly marked current month, use the most recent period
if (!currentMonthData && mostRecentPeriod && mostRecentPeriod.totalCost > 0) {
currentMonthData = mostRecentPeriod;
currentMonthData.isCurrentMonth = true;
console.log(`[DEBUG] Using most recent period as current: ${currentMonthData.date}, cost: $${currentMonthData.totalCost}`);
}
// Set current month values with proper fallback
if (currentMonthData) {
// Fix: Ensure month name is always valid - check properties in correct order
const monthName = currentMonthData.month || currentMonthData.date || currentMonthData.billing_period_label;
if (monthName && monthName !== 'undefined') {
result.currentMonth = monthName;
} else {
// Fallback to current month if data is corrupted
result.currentMonth = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
console.log(`[MONTH FIX] Corrupted month data detected, using current month: ${result.currentMonth}`);
}
result.currentMonthCost = currentMonthData.totalCost;
console.log(`[DEBUG] Current month set: ${result.currentMonth}, cost: $${result.currentMonthCost}`);
} else {
// No period data with cost - use defaults
result.currentMonth = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
result.currentMonthCost = 0;
console.log(`[DEBUG] No current period data found, using defaults: ${result.currentMonth}`);
}
const totalMonthlySpend = result.monthlyData.reduce((sum, m) => sum + m.totalCost, 0);
result.averageMonthlySpend = result.monthlyData.length > 0 ? totalMonthlySpend / result.monthlyData.length : 0;
result.projectedYearlySpend = result.averageMonthlySpend * 12;
result.quarterlyProjection = result.averageMonthlySpend * 3;
result.currentRunRate = result.averageMonthlySpend * 12;
// Set monthsTracked to the actual number of months we have data for
result.monthsTracked = result.monthlyData.length;
// Find highest spending month
const highestMonth = result.monthlyData.reduce((max, month) =>
month.totalCost > max.totalCost ? month : max
);
result.highestSpendingMonth = {
monthName: highestMonth.date,
totalCost: highestMonth.totalCost
};
// Find most active month using filtered sessions data
const monthlySessionCounts = new Map();
// Count filtered sessions by month
result.sessionsData.forEach(session => {
if (session.startTime) {
const month = session.startTime.substring(0, 7); // YYYY-MM
monthlySessionCounts.set(month, (monthlySessionCounts.get(month) || 0) + 1);
}
});
// Find month with most filtered sessions
let maxSessions = 0;
let mostActiveMonthName = null;
monthlySessionCounts.forEach((count, month) => {
if (count > maxSessions) {
maxSessions = count;
mostActiveMonthName = month;
}
});
result.mostActiveMonth = {
monthName: mostActiveMonthName,
totalSessions: maxSessions
};
// Calculate month-over-month growth rate
if (result.monthlyData.length >= 2) {
const currentMonth = result.monthlyData[0]; // Most recent (sorted newest first)
const previousMonth = result.monthlyData[1]; // Previous month
if (currentMonth && previousMonth && previousMonth.totalCost > 0) {
const growthRate = ((currentMonth.totalCost - previousMonth.totalCost) / previousMonth.totalCost) * 100;
result.monthlyGrowth = growthRate;
result.growthTrend = growthRate;
console.log(`[GROWTH] Month-over-Month: ${currentMonth.date} ($${currentMonth.totalCost}) vs ${previousMonth.date} ($${previousMonth.totalCost}) = ${growthRate.toFixed(1)}%`);
} else {
result.monthlyGrowth = 0;
result.growthTrend = 0;
}
} else {
// Not enough data for growth calculation
result.monthlyGrowth = 0;
result.growthTrend = 0;
}
} else {
// If no monthly data but we have entries, still show at least 1 month
result.monthsTracked = dbInfo.entryCount > 0 ? 1 : 0;
}
// Debug functions disabled - cost calculation working correctly
// this.debugCostCalculation();
// this.debugDuplicateAnalysis();
// Send completion message
parentPort.postMessage({
type: 'progress',
step: 'complete',
progress: 100,
message: `Database calculations complete! [DONE]`
});
const endTime = performance.now();
const calcTime = endTime - startTime;
console.log(`[OK] Worker: Blazing fast DB calculations completed in ${calcTime.toFixed(2)}ms!`);
console.log(`[PERF] Performance: ${dbInfo.entryCount} entries processed in ${calcTime.toFixed(2)}ms (${(dbInfo.entryCount / calcTime * 1000).toFixed(0)} entries/sec)`);
return result;
}
/**
* Analyze session patterns to identify potential duplication issues
*/
// Debug function to analyze cost discrepancies
debugCostCalculation() {
console.log('\n=== COST DEBUG ANALYSIS ===');
// Get raw database totals
const totalCost = this.db.getTotalCost();
const totalTokens = this.db.getTotalTokens();
const entryCount = this.db.getDbInfo().entryCount;
console.log(`DB Total Cost: $${totalCost.toFixed(2)}`);
console.log(`DB Total Tokens: ${totalTokens.toLocaleString()}`);
console.log(`DB Entry Count: ${entryCount}`);
console.log(`Average Cost per Entry: $${(totalCost / entryCount).toFixed(4)}`);
// Get sample entries for manual verification
const sampleEntries = this.db.db.prepare(`
SELECT model, input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens,
cost, timestamp
FROM usage_entries
WHERE cost > 0
ORDER BY cost DESC
LIMIT 10
`).all();
console.log('\nTop 10 most expensive entries:');
sampleEntries.forEach((entry, i) => {
const totalTokensEntry = entry.input_tokens + entry.output_tokens +
(entry.cache_creation_input_tokens || 0) +
(entry.cache_read_input_tokens || 0);
console.log(`${i+1}. ${entry.model}: $${entry.cost.toFixed(4)} (${totalTokensEntry.toLocaleString()} tokens)`);
});
// Cost distribution analysis
const costBuckets = this.db.db.prepare(`
SELECT
CASE
WHEN cost = 0 THEN '0'
WHEN cost < 0.01 THEN '<$0.01'
WHEN cost < 0.1 THEN '$0.01-$0.10'
WHEN cost < 1.0 THEN '$0.10-$1.00'
ELSE '>$1.00'
END as bucket,
COUNT(*) as count,
SUM(cost) as total_cost
FROM usage_entries
GROUP BY bucket
ORDER BY total_cost DESC
`).all();
console.log('\nCost distribution:');
costBuckets.forEach(bucket => {
console.log(`${bucket.bucket}: ${bucket.count} entries, $${bucket.total_cost.toFixed(2)} total`);
});
console.log('=== END DEBUG ===\n');
}
// Debug function to detect duplicates and analyze cost inflation
debugDuplicateAnalysis() {
console.log('\n=== DUPLICATE ANALYSIS ===');
// Check for exact duplicates by timestamp + session + tokens
const duplicateCheck = this.db.db.prepare(`
SELECT
timestamp, session_id, model,
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
cost, COUNT(*) as count
FROM usage_entries
GROUP BY timestamp, session_id, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens
HAVING COUNT(*) > 1
ORDER BY count DESC, cost DESC
LIMIT 10
`).all();
console.log(`Found ${duplicateCheck.length} sets of exact duplicates:`);
duplicateCheck.forEach((dup, i) => {
console.log(`${i+1}. ${dup.count}x duplicates: ${dup.model} $${dup.cost.toFixed(4)} (${dup.input_tokens + dup.output_tokens + (dup.cache_creation_input_tokens || 0) + (dup.cache_read_input_tokens || 0)} tokens)`);
});
// Check for near-duplicates (same session, similar time, similar tokens)
const nearDuplicates = this.db.db.prepare(`
SELECT
session_id, model,
COUNT(*) as count,
SUM(cost) as total_cost,
AVG(input_tokens) as avg_input,
AVG(output_tokens) as avg_output
FROM usage_entries
GROUP BY session_id, model
HAVING COUNT(*) > 20
ORDER BY count DESC
LIMIT 5
`).all();
console.log(`\nSessions with suspiciously many entries (>20):`);
nearDuplicates.forEach((ses, i) => {
console.log(`${i+1}. Session ${ses.session_id.substring(0,8)}: ${ses.count} entries, $${ses.total_cost.toFixed(2)} total cost`);
});
// Compare manual calculation vs stored cost for sample entries
console.log(`\nManual cost verification (first 5 entries):`);
const sampleEntries = this.db.db.prepare(`
SELECT input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, cost, model
FROM usage_entries
WHERE cost > 0
ORDER BY cost DESC
LIMIT 5
`).all();
sampleEntries.forEach((entry, i) => {
// Manual calculation using dynamic pricing from model price service
const pricing = modelPriceService.getModelPrices(entry.model);
const inputRate = pricing.input;
const outputRate = pricing.output;
const cacheCreateRate = pricing.cacheWrite;
const cacheReadRate = pricing.cacheRead;
const manualCost =
(entry.input_tokens / 1000000) * inputRate +
(entry.output_tokens / 1000000) * outputRate +
((entry.cache_creation_input_tokens || 0) / 1000000) * cacheCreateRate +
((entry.cache_read_input_tokens || 0) / 1000000) * cacheReadRate;
const storedCost = entry.cost;
const ratio = storedCost / manualCost;
console.log(`${i+1}. Manual: $${manualCost.toFixed(4)}, Stored: $${storedCost.toFixed(4)}, Ratio: ${ratio.toFixed(2)}x`);
});
// Session ID analysis
console.log('\n=== SESSION ID ANALYSIS ===');
const sessionAnalysis = this.db.db.prepare(`
SELECT
session_id,
COUNT(*) as entry_count,
SUM(cost) as session_cost,
MIN(timestamp) as first_entry,
MAX(timestamp) as last_entry
FROM usage_entries
GROUP BY session_id
ORDER BY entry_count DESC
LIMIT 10
`).all();
console.log('Top 10 sessions by entry count:');
sessionAnalysis.forEach((session, index) => {
const duration = new Date(session.last_entry) - new Date(session.first_entry);
const durationMin = Math.round(duration / (1000 * 60));
console.log(`${index + 1}. Session ${session.session_id}: ${session.entry_count} entries, $${session.session_cost.toFixed(4)}, Duration: ${durationMin}min`);
});
// Check for potential session ID collision
console.log('\nChecking for potential session ID patterns...');
const fullSessionIds = this.db.db.prepare(`
SELECT DISTINCT session_id, COUNT(*) as count
FROM usage_entries
WHERE LENGTH(session_id) > 8
GROUP BY session_id
HAVING count > 100
ORDER BY count DESC
LIMIT 5
`).all();
if (fullSessionIds.length > 0) {
console.log('Sessions with unusually high entry counts:');
fullSessionIds.forEach(session => {
console.log(`- ${session.session_id}: ${session.count} entries`);
});
}
// Check for timing patterns in problematic sessions
console.log('\n=== TIMING PATTERN ANALYSIS ===');
const timingQuery = this.db.db.prepare(`
SELECT
session_id,
timestamp,
cost,
model,
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) as seq_num
FROM usage_entries
WHERE session_id IN (
SELECT session_id FROM usage_entries
GROUP BY session_id
ORDER BY COUNT(*) DESC
LIMIT 3
)
ORDER BY session_id, timestamp
LIMIT 30
`);
const timingData = timingQuery.all();
console.log('Sample timing patterns for top 3 problematic sessions:');
let lastSession = '';
let sessionCount = 1;
timingData.forEach(row => {
if (row.session_id !== lastSession) {
console.log(`\n--- Session #${sessionCount}: ${row.session_id.substring(0,8)} ---`);
lastSession = row.session_id;
sessionCount++;
}
const date = new Date(row.timestamp);
console.log(`${row.seq_num}: ${date.toISOString()} | ${row.model} | $${row.cost.toFixed(4)}`);
});
console.log('=== END SESSION ID ANALYSIS ===');
console.log('=== END DUPLICATE ANALYSIS ===\n');
}
}
// Worker message handling
if (parentPort) {
const worker = new CoreDataWorker();
parentPort.on('message', async (data) => {
try {
const { type, ...params } = data;
if (type === 'calculateAllData') {
const result = await worker.calculateAllData(
params.usageEntries,
params.currency,
params.exchangeRates,
params.billingCycleDay || 1
);
parentPort.postMessage({ type: 'result', data: result });
}
} catch (error) {
console.error('Worker error:', error);
parentPort.postMessage({
type: 'error',
error: error.message,
stack: error.stack
});
}
});
}
module.exports = CoreDataWorker;