// server.ts - Simplified main server file // package.json dependencies needed: // npm install express mathjs lodash date-fns // npm install -D @types/express @types/node @types/lodash typescript ts-node import express from 'express'; import * as math from 'mathjs'; import * as _ from 'lodash'; import { KMeans, KMeansOptions } from './kmeans'; import { getWeekNumber, getSameWeekDayLastYear } from './time-helper'; const app = express(); app.use(express.json()); // ======================================== // TYPE DEFINITIONS // ======================================== interface DataSeries { values: number[]; labels?: string[]; } interface DataMatrix { data: number[][]; columns?: string[]; rows?: string[]; } interface Condition { field: string; operator: '>' | '<' | '=' | '>=' | '<=' | '!='; value: number | string; } interface ApiResponse { success: boolean; data?: T; error?: string; } // ======================================== // HELPER FUNCTIONS // ======================================== const handleError = (error: unknown): string => { return error instanceof Error ? error.message : 'Unknown error'; }; const validateSeries = (series: DataSeries): void => { if (!series || !Array.isArray(series.values) || series.values.length === 0) { throw new Error('Series must contain at least one value'); } }; const validateMatrix = (matrix: DataMatrix): void => { if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) { throw new Error('Matrix must contain at least one row'); } }; /** * A helper class to provide a fluent API for rolling window calculations. */ class RollingWindow { private windows: number[][]; constructor(windows: number[][]) { this.windows = windows; } mean(): number[] { return this.windows.map(window => Number(math.mean(window))); } sum(): number[] { return this.windows.map(window => _.sum(window)); } min(): number[] { return this.windows.map(window => Math.min(...window)); } max(): number[] { return this.windows.map(window => Math.max(...window)); } toArray(): number[][] { return this.windows; } } // ======================================== // ANALYTICS ENGINE (Simplified) // ======================================== class AnalyticsEngine { private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] { if (conditions.length === 0) return series.values; return series.values; // TODO: Implement filtering } // Basic statistical functions unique(series: DataSeries): number[] { validateSeries(series); return _.uniq(series.values); } mean(series: DataSeries, conditions: Condition[] = []): number { validateSeries(series); const filteredValues = this.applyConditions(series, conditions); if (filteredValues.length === 0) throw new Error('No data points match conditions'); return Number(math.mean(filteredValues)); } count(series: DataSeries, conditions: Condition[] = []): number { validateSeries(series); return this.applyConditions(series, conditions).length; } variance(series: DataSeries, conditions: Condition[] = []): number { validateSeries(series); const filteredValues = this.applyConditions(series, conditions); if (filteredValues.length === 0) throw new Error('No data points match conditions'); return Number(math.variance(filteredValues)); } standardDeviation(series: DataSeries, conditions: Condition[] = []): number { validateSeries(series); const filteredValues = this.applyConditions(series, conditions); if (filteredValues.length === 0) throw new Error('No data points match conditions'); return Number(math.std(filteredValues)); } percentile(series: DataSeries, percent: number, ascending: boolean = true, conditions: Condition[] = []): number { validateSeries(series); const filteredValues = this.applyConditions(series, conditions); if (filteredValues.length === 0) throw new Error('No data points match conditions'); const sorted = ascending ? _.sortBy(filteredValues) : _.sortBy(filteredValues).reverse(); const index = (percent / 100) * (sorted.length - 1); const lower = Math.floor(index); const upper = Math.ceil(index); const weight = index % 1; return sorted[lower] * (1 - weight) + sorted[upper] * weight; } median(series: DataSeries, conditions: Condition[] = []): number { return this.percentile(series, 50, true, conditions); } mode(series: DataSeries, conditions: Condition[] = []): number[] { validateSeries(series); const filteredValues = this.applyConditions(series, conditions); const frequency = _.countBy(filteredValues); const maxFreq = Math.max(...Object.values(frequency)); return Object.keys(frequency) .filter(key => frequency[key] === maxFreq) .map(Number); } max(series: DataSeries, conditions: Condition[] = []): number { validateSeries(series); const filteredValues = this.applyConditions(series, conditions); if (filteredValues.length === 0) throw new Error('No data points match conditions'); return Math.max(...filteredValues); } min(series: DataSeries, conditions: Condition[] = []): number { validateSeries(series); const filteredValues = this.applyConditions(series, conditions); if (filteredValues.length === 0) throw new Error('No data points match conditions'); return Math.min(...filteredValues); } correlation(series1: DataSeries, series2: DataSeries): number { validateSeries(series1); validateSeries(series2); if (series1.values.length !== series2.values.length) { throw new Error('Series must have same length for correlation'); } const x = series1.values; const y = series2.values; const n = x.length; const sumX = _.sum(x); const sumY = _.sum(y); const sumXY = _.sum(x.map((xi, i) => xi * y[i])); const sumX2 = _.sum(x.map(xi => xi * xi)); const sumY2 = _.sum(y.map(yi => yi * yi)); const numerator = n * sumXY - sumX * sumY; const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)); return numerator / denominator; } // Rolling window functions rolling(series: DataSeries, windowSize: number): RollingWindow { validateSeries(series); if (windowSize <= 0) { throw new Error('Window size must be a positive number.'); } if (series.values.length < windowSize) { return new RollingWindow([]); } const windows: number[][] = []; for (let i = 0; i <= series.values.length - windowSize; i++) { const window = series.values.slice(i, i + windowSize); windows.push(window); } return new RollingWindow(windows); } movingAverage(series: DataSeries, windowSize: number): number[] { return this.rolling(series, windowSize).mean(); } // K-means wrapper (uses imported KMeans class) kmeans(matrix: DataMatrix, nClusters: number, options: KMeansOptions = {}): { clusters: number[][][], centroids: number[][] } { validateMatrix(matrix); const points: number[][] = matrix.data; // Use the new MiniBatchKMeans class const kmeans = new KMeans(points, nClusters, options); const result = kmeans.run(); const centroids = result.clusters.map(c => c.centroid); const clusters = result.clusters.map(c => c.points); return { clusters, centroids }; } // Time helper wrapper functions getWeekNumber(dateString: string): number { return getWeekNumber(dateString); } getSameWeekDayLastYear(dateString: string): string { return getSameWeekDayLastYear(dateString); } // Retail functions purchaseRate(productPurchases: number, totalTransactions: number): number { if (totalTransactions === 0) throw new Error('Total transactions cannot be zero'); return (productPurchases / totalTransactions) * 100; } liftValue(jointPurchaseRate: number, productAPurchaseRate: number, productBPurchaseRate: number): number { const expectedJointRate = productAPurchaseRate * productBPurchaseRate; if (expectedJointRate === 0) throw new Error('Expected joint rate cannot be zero'); return jointPurchaseRate / expectedJointRate; } costRatio(cost: number, salePrice: number): number { if (salePrice === 0) throw new Error('Sale price cannot be zero'); return cost / salePrice; } grossMarginRate(salePrice: number, cost: number): number { if (salePrice === 0) throw new Error('Sale price cannot be zero'); return (salePrice - cost) / salePrice; } averageSpendPerCustomer(totalRevenue: number, numberOfCustomers: number): number { if (numberOfCustomers === 0) { throw new Error('Number of customers cannot be zero'); } return totalRevenue / numberOfCustomers; } purchaseIndex(totalItemsSold: number, numberOfCustomers: number): number { if (numberOfCustomers === 0) { throw new Error('Number of customers cannot be zero'); } return (totalItemsSold / numberOfCustomers) * 1000; } } // Initialize analytics engine const analytics = new AnalyticsEngine(); // ======================================== // ROUTE HELPER FUNCTION // ======================================== const createRoute = ( app: express.Application, method: 'get' | 'post' | 'put' | 'delete', path: string, handler: (req: express.Request) => T ) => { app[method](path, (req, res) => { try { const result = handler(req); res.status(200).json({ success: true, data: result } as ApiResponse); } catch (error) { const errorMessage = handleError(error); res.status(400).json({ success: false, error: errorMessage } as ApiResponse); } }); }; // ======================================== // API ROUTES // ======================================== app.get('/api/health', (req, res) => { res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() }); }); // Statistical function routes createRoute(app, 'post', '/api/unique', (req) => analytics.unique(req.body.series)); createRoute(app, 'post', '/api/mean', (req) => analytics.mean(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/count', (req) => analytics.count(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/variance', (req) => analytics.variance(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/std', (req) => analytics.standardDeviation(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/percentile', (req) => analytics.percentile(req.body.series, req.body.percent, req.body.ascending, req.body.conditions)); createRoute(app, 'post', '/api/median', (req) => analytics.median(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/mode', (req) => analytics.mode(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/max', (req) => analytics.max(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/min', (req) => analytics.min(req.body.series, req.body.conditions)); createRoute(app, 'post', '/api/correlation', (req) => analytics.correlation(req.body.series1, req.body.series2)); // Time series routes createRoute(app, 'post', '/api/series/moving-average', (req) => { const { series, windowSize } = req.body; return analytics.movingAverage(series, windowSize); }); createRoute(app, 'post', '/api/series/rolling', (req) => { const { series, windowSize } = req.body; return analytics.rolling(series, windowSize).toArray(); }); // Machine learning routes createRoute(app, 'post', '/api/ml/kmeans', (req) => { return analytics.kmeans(req.body.matrix, req.body.nClusters, req.body.options); }); // Time helper routes createRoute(app, 'post', '/api/time/week-number', (req) => { const { date } = req.body; return analytics.getWeekNumber(date); }); createRoute(app, 'post', '/api/time/same-day-last-year', (req) => { const { date } = req.body; return analytics.getSameWeekDayLastYear(date); }); // Retail analytics routes createRoute(app, 'post', '/api/retail/purchase-rate', (req) => analytics.purchaseRate(req.body.productPurchases, req.body.totalTransactions)); createRoute(app, 'post', '/api/retail/lift-value', (req) => analytics.liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate)); createRoute(app, 'post', '/api/retail/cost-ratio', (req) => analytics.costRatio(req.body.cost, req.body.salePrice)); createRoute(app, 'post', '/api/retail/gross-margin', (req) => analytics.grossMarginRate(req.body.salePrice, req.body.cost)); createRoute(app, 'post', '/api/retail/average-spend', (req) => { const { totalRevenue, numberOfCustomers } = req.body; return analytics.averageSpendPerCustomer(totalRevenue, numberOfCustomers); }); createRoute(app, 'post', '/api/retail/purchase-index', (req) => { const { totalItemsSold, numberOfCustomers } = req.body; return analytics.purchaseIndex(totalItemsSold, numberOfCustomers); }); // ======================================== // ERROR HANDLING // ======================================== app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error(err.stack); res.status(500).json({ success: false, error: 'Internal server error' } as ApiResponse); }); // app.use('*', (req, res) => { // res.status(404).json({ success: false, error: 'Endpoint not found' } as ApiResponse); // }); app.use('*', (req, res) => { res.status(404).json({ success: false, error: 'Endpoint not found' }); }); // ======================================== // SERVER STARTUP // ======================================== const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Analytics API server running on port ${PORT}`); console.log(`Health check: http://localhost:${PORT}/api/health`); console.log('\n=== Available Endpoints ==='); console.log('GET /api/health'); console.log('POST /api/mean'); console.log('POST /api/variance'); console.log('POST /api/ml/kmeans <-- uses external kmeans.ts'); console.log('POST /api/time/week-number <-- uses external time-helper.ts'); console.log('POST /api/time/same-day-last-year'); console.log('POST /api/series/moving-average'); console.log('... and more'); }); export default app;