/** * Trend Analysis for Analytics * Provides trend detection, forecasting, and pattern analysis */ import { TimeSeriesDataPoint, TrendDirection, TrendData, TimeRange } from './types'; import { mean, standardDeviation, percentageChange, movingAverage } from './metrics'; import { format, parseISO, differenceInDays } from 'date-fns'; /** * Analyze trend from time series data */ export function analyzeTrend(data: TimeSeriesDataPoint[]): TrendData { if (data.length < 2) { return { metric: 'Unknown', currentValue: data[0]?.value || 0, previousValue: 0, change: 0, changePercent: 0, direction: 'stable', period: 'N/A', }; } const midpoint = Math.floor(data.length / 2); const firstHalf = data.slice(0, midpoint); const secondHalf = data.slice(midpoint); const firstAvg = mean(firstHalf.map(d => d.value)); const secondAvg = mean(secondHalf.map(d => d.value)); const change = secondAvg - firstAvg; const changePercent = percentageChange(secondAvg, firstAvg); let direction: TrendDirection = 'stable'; if (changePercent > 5) direction = 'up'; else if (changePercent < -5) direction = 'down'; return { metric: '', currentValue: secondAvg, previousValue: firstAvg, change, changePercent: Math.round(changePercent * 10) / 10, direction, period: `${data[0].timestamp} - ${data[data.length - 1].timestamp}`, }; } /** * Calculate linear regression for forecasting */ export function linearRegression(data: TimeSeriesDataPoint[]): { slope: number; intercept: number; rSquared: number; } { const n = data.length; if (n < 2) return { slope: 0, intercept: 0, rSquared: 0 }; let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0; data.forEach((point, i) => { const x = i; const y = point.value; sumX += x; sumY += y; sumXY += x * y; sumX2 += x * x; sumY2 += y * y; }); const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); const intercept = (sumY - slope * sumX) / n; // Calculate R-squared const yMean = sumY / n; let ssRes = 0, ssTot = 0; data.forEach((point, i) => { const predicted = slope * i + intercept; ssRes += Math.pow(point.value - predicted, 2); ssTot += Math.pow(point.value - yMean, 2); }); const rSquared = ssTot === 0 ? 1 : 1 - ssRes / ssTot; return { slope: Math.round(slope * 1000) / 1000, intercept: Math.round(intercept * 1000) / 1000, rSquared: Math.round(rSquared * 1000) / 1000, }; } /** * Forecast future values using linear regression */ export function forecast( data: TimeSeriesDataPoint[], periodsAhead: number ): TimeSeriesDataPoint[] { const regression = linearRegression(data); const predictions: TimeSeriesDataPoint[] = []; const lastIndex = data.length - 1; for (let i = 1; i <= periodsAhead; i++) { const predictedValue = regression.slope * (lastIndex + i) + regression.intercept; predictions.push({ timestamp: `+${i}`, value: Math.max(0, Math.round(predictedValue * 100) / 100), label: `Forecast ${i}`, }); } return predictions; } /** * Detect seasonality in data */ export function detectSeasonality(data: TimeSeriesDataPoint[]): { hasSeasonality: boolean; period: number; strength: number; } { if (data.length < 14) { return { hasSeasonality: false, period: 0, strength: 0 }; } // Try common periods: 7 days (weekly), 30 days (monthly) const periods = [7, 14, 30]; let bestPeriod = 0; let bestCorrelation = 0; for (const period of periods) { if (data.length < period * 2) continue; let correlation = 0; let count = 0; for (let i = period; i < data.length; i++) { correlation += Math.abs(data[i].value - data[i - period].value); count++; } const avgCorr = count > 0 ? 1 - correlation / (count * mean(data.map(d => d.value))) : 0; if (avgCorr > bestCorrelation) { bestCorrelation = avgCorr; bestPeriod = period; } } return { hasSeasonality: bestCorrelation > 0.5, period: bestPeriod, strength: Math.round(bestCorrelation * 100) / 100, }; } /** * Identify peaks and valleys in data */ export function findPeaksAndValleys( data: TimeSeriesDataPoint[], sensitivity: number = 1.5 ): { peaks: TimeSeriesDataPoint[]; valleys: TimeSeriesDataPoint[]; } { const peaks: TimeSeriesDataPoint[] = []; const valleys: TimeSeriesDataPoint[] = []; if (data.length < 3) return { peaks, valleys }; const avg = mean(data.map(d => d.value)); const std = standardDeviation(data.map(d => d.value)); const threshold = std * sensitivity; for (let i = 1; i < data.length - 1; i++) { const prev = data[i - 1].value; const curr = data[i].value; const next = data[i + 1].value; // Peak detection if (curr > prev && curr > next && curr > avg + threshold) { peaks.push(data[i]); } // Valley detection if (curr < prev && curr < next && curr < avg - threshold) { valleys.push(data[i]); } } return { peaks, valleys }; } /** * Calculate trend momentum */ export function calculateMomentum(data: TimeSeriesDataPoint[], lookback: number = 5): number { if (data.length < lookback) return 0; const recent = data.slice(-lookback); const older = data.slice(-lookback * 2, -lookback); if (older.length === 0) return 0; const recentAvg = mean(recent.map(d => d.value)); const olderAvg = mean(older.map(d => d.value)); return Math.round(((recentAvg - olderAvg) / olderAvg) * 100 * 10) / 10; } /** * Smooth data using exponential smoothing */ export function exponentialSmoothing( data: TimeSeriesDataPoint[], alpha: number = 0.3 ): TimeSeriesDataPoint[] { if (data.length === 0) return []; const smoothed: TimeSeriesDataPoint[] = [data[0]]; for (let i = 1; i < data.length; i++) { const smoothedValue = alpha * data[i].value + (1 - alpha) * smoothed[i - 1].value; smoothed.push({ ...data[i], value: Math.round(smoothedValue * 100) / 100, }); } return smoothed; } /** * Generate trend summary text */ export function generateTrendSummary(data: TimeSeriesDataPoint[], metricName: string): string { if (data.length < 2) return `Insufficient data for ${metricName} analysis.`; const trend = analyzeTrend(data); const regression = linearRegression(data); const { peaks, valleys } = findPeaksAndValleys(data); const momentum = calculateMomentum(data); let summary = ''; // Overall direction if (trend.direction === 'up') { summary += `${metricName} is trending upward with a ${Math.abs(trend.changePercent).toFixed(1)}% increase. `; } else if (trend.direction === 'down') { summary += `${metricName} is trending downward with a ${Math.abs(trend.changePercent).toFixed(1)}% decrease. `; } else { summary += `${metricName} remains relatively stable. `; } // Trend strength if (regression.rSquared > 0.8) { summary += 'The trend is strong and consistent. '; } else if (regression.rSquared > 0.5) { summary += 'The trend is moderate with some variability. '; } else { summary += 'Data shows high variability. '; } // Momentum if (momentum > 10) { summary += 'Recent acceleration detected. '; } else if (momentum < -10) { summary += 'Recent deceleration detected. '; } // Notable events if (peaks.length > 0) { summary += `${peaks.length} notable peak(s) observed. `; } if (valleys.length > 0) { summary += `${valleys.length} notable dip(s) observed. `; } return summary.trim(); } /** * Compare two time series for similarity */ export function compareTimeSeries( series1: TimeSeriesDataPoint[], series2: TimeSeriesDataPoint[] ): { correlation: number; leadLag: number; divergence: number; } { // Ensure same length const minLength = Math.min(series1.length, series2.length); const s1 = series1.slice(0, minLength).map(d => d.value); const s2 = series2.slice(0, minLength).map(d => d.value); // Calculate correlation const mean1 = mean(s1); const mean2 = mean(s2); let numerator = 0, denom1 = 0, denom2 = 0; for (let i = 0; i < minLength; i++) { const d1 = s1[i] - mean1; const d2 = s2[i] - mean2; numerator += d1 * d2; denom1 += d1 * d1; denom2 += d2 * d2; } const correlation = denom1 * denom2 === 0 ? 0 : numerator / Math.sqrt(denom1 * denom2); // Calculate divergence (normalized difference) const divergence = mean(s1.map((v, i) => Math.abs(v - s2[i]))) / ((mean1 + mean2) / 2); return { correlation: Math.round(correlation * 1000) / 1000, leadLag: 0, // Simplified - full cross-correlation would require more computation divergence: Math.round(divergence * 1000) / 1000, }; } /** * Get trend confidence level */ export function getTrendConfidence(data: TimeSeriesDataPoint[]): { level: 'high' | 'medium' | 'low'; score: number; factors: string[]; } { const factors: string[] = []; let score = 50; // Data quantity factor if (data.length >= 30) { score += 15; factors.push('Sufficient data points'); } else if (data.length >= 14) { score += 10; factors.push('Moderate data points'); } else { score -= 10; factors.push('Limited data points'); } // Consistency factor const std = standardDeviation(data.map(d => d.value)); const avg = mean(data.map(d => d.value)); const cv = avg !== 0 ? std / avg : 0; if (cv < 0.2) { score += 15; factors.push('Low variability'); } else if (cv < 0.5) { score += 5; factors.push('Moderate variability'); } else { score -= 10; factors.push('High variability'); } // Trend strength const regression = linearRegression(data); if (regression.rSquared > 0.7) { score += 20; factors.push('Strong trend fit'); } else if (regression.rSquared > 0.4) { score += 10; factors.push('Moderate trend fit'); } else { score -= 5; factors.push('Weak trend fit'); } const level = score >= 70 ? 'high' : score >= 50 ? 'medium' : 'low'; return { level, score: Math.min(100, Math.max(0, score)), factors, }; } /** * Calculate year-over-year comparison */ export function yearOverYearComparison( currentPeriod: TimeSeriesDataPoint[], previousPeriod: TimeSeriesDataPoint[] ): { currentTotal: number; previousTotal: number; change: number; changePercent: number; trend: TrendDirection; } { const currentTotal = currentPeriod.reduce((sum, d) => sum + d.value, 0); const previousTotal = previousPeriod.reduce((sum, d) => sum + d.value, 0); const change = currentTotal - previousTotal; const changePercent = percentageChange(currentTotal, previousTotal); return { currentTotal: Math.round(currentTotal * 100) / 100, previousTotal: Math.round(previousTotal * 100) / 100, change: Math.round(change * 100) / 100, changePercent: Math.round(changePercent * 10) / 10, trend: changePercent > 5 ? 'up' : changePercent < -5 ? 'down' : 'stable', }; }