From 80aae6b21cc71bb95ee618f55b4e54db1db1626d Mon Sep 17 00:00:00 2001 From: raymond Date: Fri, 5 Sep 2025 00:17:31 +0000 Subject: [PATCH] add linear regression, swagger for documentation functions --- prediction.ts | 101 +++ server.ts | 1832 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 1524 insertions(+), 409 deletions(-) create mode 100644 prediction.ts diff --git a/prediction.ts b/prediction.ts new file mode 100644 index 0000000..eb46525 --- /dev/null +++ b/prediction.ts @@ -0,0 +1,101 @@ +import * as math from 'mathjs'; + +// The structure for the returned regression model +export interface LinearRegressionModel { + slope: number; + intercept: number; + predict: (x: number) => number; +} + +// The structure for the full forecast output +export interface ForecastResult { + forecast: number[]; + predictionIntervals: { + upperBound: number[]; + lowerBound: number[]; + }; + modelParameters: { + slope: number; + intercept: number; + }; +} + +/** + * Calculates the linear regression model from a time series. + * @param yValues The historical data points (e.g., sales per month). + * @returns {LinearRegressionModel} An object containing the model's parameters and a predict function. + */ +export function calculateLinearRegression(yValues: number[]): LinearRegressionModel { + if (yValues.length < 2) { + throw new Error('At least two data points are required for linear regression.'); + } + + const xValues = Array.from({ length: yValues.length }, (_, i) => i); + + const meanX = Number(math.mean(xValues)); + const meanY = Number(math.mean(yValues)); + const stdDevX = Number(math.std(xValues, 'uncorrected')); + const stdDevY = Number(math.std(yValues, 'uncorrected')); + + // Ensure stdDevX is not zero to avoid division by zero + if (stdDevX === 0) { + // This happens if all xValues are the same, which is impossible in this time series context, + // but it's good practice to handle. A vertical line has an infinite slope. + // For simplicity, we can return a model with zero slope. + return { slope: 0, intercept: meanY, predict: (x: number) => meanY }; + } + + // Cast the result of math.sum to a Number + const correlationNumerator = Number(math.sum(xValues.map((x, i) => (x - meanX) * (yValues[i] - meanY)))); + + const correlation = correlationNumerator / ((xValues.length - 1) * stdDevX * stdDevY); + + const slope = correlation * (stdDevY / stdDevX); + const intercept = meanY - slope * meanX; + + const predict = (x: number): number => slope * x + intercept; + + return { slope, intercept, predict }; +} + +/** + * Generates a forecast for a specified number of future periods. + * @param model The calculated linear regression model. + * @param historicalDataLength The number of historical data points. + * @param forecastPeriods The number of future periods to predict. + * @returns {number[]} An array of forecasted values. + */ +export function generateForecast(model: LinearRegressionModel, historicalDataLength: number, forecastPeriods: number): number[] { + const forecast: number[] = []; + const startPeriod = historicalDataLength; + + for (let i = 0; i < forecastPeriods; i++) { + const futureX = startPeriod + i; + forecast.push(model.predict(futureX)); + } + return forecast; +} + +/** + * Calculates prediction intervals to show the range of uncertainty. + * @param yValues The original historical data. + * @param model The calculated linear regression model. + * @param forecast The array of forecasted values. + * @returns An object with upperBound and lowerBound arrays. + */ +export function calculatePredictionIntervals(yValues: number[], model: LinearRegressionModel, forecast: number[]) { + const n = yValues.length; + const residualsSquaredSum = yValues.reduce((sum, y, i) => { + const predictedY = model.predict(i); + return sum + (y - predictedY) ** 2; + }, 0); + const stdError = Math.sqrt(residualsSquaredSum / (n - 2)); + + const zScore = 1.96; // For a 95% confidence level + const marginOfError = zScore * stdError; + + const upperBound = forecast.map(val => val + marginOfError); + const lowerBound = forecast.map(val => val - marginOfError); + + return { upperBound, lowerBound }; +} \ No newline at end of file diff --git a/server.ts b/server.ts index b686305..3661f26 100644 --- a/server.ts +++ b/server.ts @@ -1,410 +1,1424 @@ -// 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'); -}); - +// 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 swaggerJsdoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; +import * as math from 'mathjs'; +import * as _ from 'lodash'; +import { KMeans, KMeansOptions } from './kmeans'; +import { getWeekNumber, getSameWeekDayLastYear } from './time-helper'; +import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction'; + +const app = express(); +app.use(express.json()); +const PORT = process.env.PORT || 3000; + +const swaggerOptions = { + swaggerDefinition: { + openapi: '3.0.0', + info: { + title: 'My Express API', + version: '1.0.0', + description: 'API documentation for my awesome Express app', + }, + servers: [ + { + url: `http://localhost:${PORT}`, + }, + ], + }, + // Paths to files containing OpenAPI definitions + apis: ["./*.ts"], // Make sure this path is correct +}; + +const swaggerSpec = swaggerJsdoc(swaggerOptions); + +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + +// ======================================== +// 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); + const filteredValues = this.applyConditions(series, conditions); + if (filteredValues.length === 0) throw new Error('No data points match conditions'); + return filteredValues.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; + } + + // ======================================== + // Prediction functions + // ======================================== + + timeSeriesForecast(series: DataSeries, forecastPeriods: number): ForecastResult { + validateSeries(series); + + const model = calculateLinearRegression(series.values); + const forecast = generateForecast(model, series.values.length, forecastPeriods); + const predictionIntervals = calculatePredictionIntervals(series.values, model, forecast); + + return { + forecast, + predictionIntervals, + modelParameters: { + slope: model.slope, + intercept: model.intercept, + }, + }; + } +} + +// Initialize analytics engine +const analytics = new AnalyticsEngine(); + +// ======================================== +// API ROUTES +// ======================================== + +/** + * @swagger + * /api/health: + * get: + * summary: Health check endpoint + * description: Returns the health status of the API + * responses: + * 200: + * description: API is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: OK + * timestamp: + * type: string + * format: date-time + */ +app.get('/api/health', (req, res) => { + res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() }); +}); + +/** + * @swagger + * /api/unique: + * post: + * summary: Get unique values from a data series + * description: Returns an array of unique values from the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * responses: + * 200: + * description: Unique values calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/unique', (req, res) => { + try { + const result = analytics.unique(req.body.series); + 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); + } +}); + +/** + * @swagger + * /api/mean: + * post: + * summary: Calculate mean of a data series + * description: Returns the arithmetic mean of the provided data series, optionally filtered by conditions + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Mean calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/mean', (req, res) => { + try { + const result = analytics.mean(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/count: + * post: + * summary: Count data points in a series + * description: Returns the count of data points in the series, optionally filtered by conditions + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Count calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/count', (req, res) => { + try { + const result = analytics.count(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/variance: + * post: + * summary: Calculate variance of a data series + * description: Returns the variance of the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Variance calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/variance', (req, res) => { + try { + const result = analytics.variance(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/std: + * post: + * summary: Calculate standard deviation of a data series + * description: Returns the standard deviation of the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Standard deviation calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/std', (req, res) => { + try { + const result = analytics.standardDeviation(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/percentile: + * post: + * summary: Calculate percentile of a data series + * description: Returns the specified percentile of the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * percent: + * type: number + * description: Percentile to calculate (0-100) + * example: 95 + * ascending: + * type: boolean + * description: Sort order + * default: true + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Percentile calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/percentile', (req, res) => { + try { + const result = analytics.percentile(req.body.series, req.body.percent, req.body.ascending, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/median: + * post: + * summary: Calculate median of a data series + * description: Returns the median (50th percentile) of the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Median calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/median', (req, res) => { + try { + const result = analytics.median(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/mode: + * post: + * summary: Calculate mode of a data series + * description: Returns the mode (most frequent values) of the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Mode calculated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/mode', (req, res) => { + try { + const result = analytics.mode(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/max: + * post: + * summary: Find maximum value in a data series + * description: Returns the maximum value from the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Maximum value found successfully + * 400: + * description: Invalid input data + */ +app.post('/api/max', (req, res) => { + try { + const result = analytics.max(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/min: + * post: + * summary: Find minimum value in a data series + * description: Returns the minimum value from the provided data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * 200: + * description: Minimum value found successfully + * 400: + * description: Invalid input data + */ +app.post('/api/min', (req, res) => { + try { + const result = analytics.min(req.body.series, req.body.conditions); + 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); + } +}); + +/** + * @swagger + * /api/correlation: + * post: + * summary: Calculate correlation between two data series + * description: Returns the Pearson correlation coefficient between two data series + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series1: + * $ref: '#/components/schemas/DataSeries' + * series2: + * $ref: '#/components/schemas/DataSeries' + * responses: + * 200: + * description: Correlation calculated successfully + * 400: + * description: Invalid input data or series have different lengths + */ +app.post('/api/correlation', (req, res) => { + try { + const result = analytics.correlation(req.body.series1, req.body.series2); + 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); + } +}); + +/** + * @swagger + * /api/series/moving-average: + * post: + * summary: Calculate moving average of a data series + * description: Returns the moving average of the provided data series with specified window size + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * windowSize: + * type: integer + * description: Size of the moving window + * minimum: 1 + * example: 5 + * responses: + * 200: + * description: Moving average calculated successfully + * 400: + * description: Invalid input data or window size + */ +app.post('/api/series/moving-average', (req, res) => { + try { + const { series, windowSize } = req.body; + const result = analytics.movingAverage(series, windowSize); + 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); + } +}); + +/** + * @swagger + * /api/series/rolling: + * post: + * summary: Get rolling windows of a data series + * description: Returns rolling windows of the provided data series with specified window size + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * windowSize: + * type: integer + * description: Size of the rolling window + * minimum: 1 + * example: 3 + * responses: + * 200: + * description: Rolling windows calculated successfully + * 400: + * description: Invalid input data or window size + */ +app.post('/api/series/rolling', (req, res) => { + try { + const { series, windowSize } = req.body; + const result = analytics.rolling(series, windowSize).toArray(); + 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); + } +}); + +/** + * @swagger + * /api/ml/kmeans: + * post: + * summary: Perform K-means clustering + * description: Performs K-means clustering on the provided data matrix + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * matrix: + * $ref: '#/components/schemas/DataMatrix' + * nClusters: + * type: integer + * description: Number of clusters + * minimum: 1 + * example: 3 + * options: + * type: object + * description: K-means options + * responses: + * 200: + * description: K-means clustering completed successfully + * 400: + * description: Invalid input data + */ +app.post('/api/ml/kmeans', (req, res) => { + try { + const result = analytics.kmeans(req.body.matrix, req.body.nClusters, req.body.options); + res.status(200).json({ success: true, data: result } as ApiResponse<{ clusters: number[][][], centroids: number[][] }>); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse<{ clusters: number[][][], centroids: number[][] }>); + } +}); + +/** + * @swagger + * /api/time/week-number: + * post: + * summary: Get week number from date + * description: Returns the ISO week number for the provided date string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * date: + * type: string + * format: date + * description: Date string in ISO format + * example: "2024-03-15" + * responses: + * 200: + * description: Week number calculated successfully + * 400: + * description: Invalid date format + */ +app.post('/api/time/week-number', (req, res) => { + try { + const { date } = req.body; + const result = analytics.getWeekNumber(date); + 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); + } +}); + +/** + * @swagger + * /api/time/same-day-last-year: + * post: + * summary: Get same day of week from last year + * description: Returns the date string for the same day of the week from the previous year + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * date: + * type: string + * format: date + * description: Date string in ISO format + * example: "2024-03-15" + * responses: + * 200: + * description: Same day last year calculated successfully + * 400: + * description: Invalid date format + */ +app.post('/api/time/same-day-last-year', (req, res) => { + try { + const { date } = req.body; + const result = analytics.getSameWeekDayLastYear(date); + 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); + } +}); + +/** + * @swagger + * /api/retail/purchase-rate: + * post: + * summary: Calculate purchase rate + * description: Calculates the purchase rate as a percentage of product purchases over total transactions + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * productPurchases: + * type: number + * description: Number of product purchases + * example: 150 + * totalTransactions: + * type: number + * description: Total number of transactions + * example: 1000 + * responses: + * 200: + * description: Purchase rate calculated successfully + * 400: + * description: Invalid input data or division by zero + */ +app.post('/api/retail/purchase-rate', (req, res) => { + try { + const result = analytics.purchaseRate(req.body.productPurchases, req.body.totalTransactions); + 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); + } +}); + +/** + * @swagger + * /api/retail/lift-value: + * post: + * summary: Calculate lift value + * description: Calculates the lift value for market basket analysis + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * jointPurchaseRate: + * type: number + * description: Joint purchase rate of both products + * example: 0.05 + * productAPurchaseRate: + * type: number + * description: Purchase rate of product A + * example: 0.2 + * productBPurchaseRate: + * type: number + * description: Purchase rate of product B + * example: 0.3 + * responses: + * 200: + * description: Lift value calculated successfully + * 400: + * description: Invalid input data or division by zero + */ +app.post('/api/retail/lift-value', (req, res) => { + try { + const result = analytics.liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate); + 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); + } +}); + +/** + * @swagger + * /api/retail/cost-ratio: + * post: + * summary: Calculate cost ratio + * description: Calculates the cost ratio (cost divided by sale price) + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * cost: + * type: number + * description: Cost of the product + * example: 50 + * salePrice: + * type: number + * description: Sale price of the product + * example: 100 + * responses: + * 200: + * description: Cost ratio calculated successfully + * 400: + * description: Invalid input data or division by zero + */ +app.post('/api/retail/cost-ratio', (req, res) => { + try { + const result = analytics.costRatio(req.body.cost, req.body.salePrice); + 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); + } +}); + +/** + * @swagger + * /api/retail/gross-margin: + * post: + * summary: Calculate gross margin rate + * description: Calculates the gross margin rate as a percentage + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * salePrice: + * type: number + * description: Sale price of the product + * example: 100 + * cost: + * type: number + * description: Cost of the product + * example: 60 + * responses: + * 200: + * description: Gross margin rate calculated successfully + * 400: + * description: Invalid input data or division by zero + */ +app.post('/api/retail/gross-margin', (req, res) => { + try { + const result = analytics.grossMarginRate(req.body.salePrice, req.body.cost); + 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); + } +}); + +/** + * @swagger + * /api/retail/average-spend: + * post: + * summary: Calculate average spend per customer + * description: Calculates the average amount spent per customer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * totalRevenue: + * type: number + * description: Total revenue + * example: 50000 + * numberOfCustomers: + * type: number + * description: Number of customers + * example: 500 + * responses: + * 200: + * description: Average spend calculated successfully + * 400: + * description: Invalid input data or division by zero + */ +app.post('/api/retail/average-spend', (req, res) => { + try { + const { totalRevenue, numberOfCustomers } = req.body; + const result = analytics.averageSpendPerCustomer(totalRevenue, numberOfCustomers); + 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); + } +}); + +/** + * @swagger + * /api/retail/purchase-index: + * post: + * summary: Calculate purchase index + * description: Calculates the purchase index (items per 1000 customers) + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * totalItemsSold: + * type: number + * description: Total number of items sold + * example: 2500 + * numberOfCustomers: + * type: number + * description: Number of customers + * example: 1000 + * responses: + * 200: + * description: Purchase index calculated successfully + * 400: + * description: Invalid input data or division by zero + */ +app.post('/api/retail/purchase-index', (req, res) => { + try { + const { totalItemsSold, numberOfCustomers } = req.body; + const result = analytics.purchaseIndex(totalItemsSold, numberOfCustomers); + 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); + } +}); + +/** + * @swagger + * /api/predict/forecast: + * post: + * summary: Generate time series forecast + * description: Generates a forecast for time series data using linear regression + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * forecastPeriods: + * type: integer + * description: Number of periods to forecast + * minimum: 1 + * example: 10 + * responses: + * 200: + * description: Forecast generated successfully + * 400: + * description: Invalid input data + */ +app.post('/api/predict/forecast', (req, res) => { + try { + const { series, forecastPeriods } = req.body; + const result = analytics.timeSeriesForecast(series, forecastPeriods); + 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); + } +}); + +// ======================================== +// SWAGGER COMPONENTS +// ======================================== + +/** + * @swagger + * components: + * schemas: + * DataSeries: + * type: object + * required: + * - values + * properties: + * values: + * type: array + * items: + * type: number + * description: Array of numerical values + * example: [1, 2, 3, 4, 5] + * labels: + * type: array + * items: + * type: string + * description: Optional labels for the values + * example: ["Jan", "Feb", "Mar", "Apr", "May"] + * + * DataMatrix: + * type: object + * required: + * - data + * properties: + * data: + * type: array + * items: + * type: array + * items: + * type: number + * description: 2D array of numerical values + * example: [[1, 2], [3, 4], [5, 6]] + * columns: + * type: array + * items: + * type: string + * description: Optional column names + * example: ["x", "y"] + * rows: + * type: array + * items: + * type: string + * description: Optional row names + * example: ["row1", "row2", "row3"] + * + * Condition: + * type: object + * required: + * - field + * - operator + * - value + * properties: + * field: + * type: string + * description: Field name to apply condition on + * example: "value" + * operator: + * type: string + * enum: [">", "<", "=", ">=", "<=", "!="] + * description: Comparison operator + * example: ">" + * value: + * oneOf: + * - type: number + * - type: string + * description: Value to compare against + * example: 10 + * + * ApiResponse: + * type: object + * properties: + * success: + * type: boolean + * description: Whether the request was successful + * data: + * description: Response data (varies by endpoint) + * error: + * type: string + * description: Error message if success is false + */ + +/** + * @swagger + * /api/docs/export/json: + * get: + * summary: Export API documentation as JSON + * description: Returns the complete OpenAPI specification in JSON format + * responses: + * 200: + * description: OpenAPI specification in JSON format + * content: + * application/json: + * schema: + * type: object + */ +app.get('/api/docs/export/json', (req, res) => { + res.setHeader('Content-Disposition', 'attachment; filename="api-documentation.json"'); + res.setHeader('Content-Type', 'application/json'); + res.json(swaggerSpec); +}); + +/** + * @swagger + * /api/docs/export/yaml: + * get: + * summary: Export API documentation as YAML + * description: Returns the complete OpenAPI specification in YAML format + * responses: + * 200: + * description: OpenAPI specification in YAML format + * content: + * text/yaml: + * schema: + * type: string + */ +app.get('/api/docs/export/yaml', (req, res) => { + const yaml = require('js-yaml'); + const yamlString = yaml.dump(swaggerSpec, { indent: 2 }); + + res.setHeader('Content-Disposition', 'attachment; filename="api-documentation.yaml"'); + res.setHeader('Content-Type', 'text/yaml'); + res.send(yamlString); +}); + +/** + * @swagger + * /api/docs/export/html: + * get: + * summary: Export API documentation as HTML + * description: Returns a standalone HTML file with the complete API documentation + * responses: + * 200: + * description: Standalone HTML documentation + * content: + * text/html: + * schema: + * type: string + */ +app.get('/api/docs/export/html', (req, res) => { + const htmlTemplate = ` + + + + + + API Documentation + + + + +
+ + + + +`; + + res.setHeader('Content-Disposition', 'attachment; filename="api-documentation.html"'); + res.setHeader('Content-Type', 'text/html'); + res.send(htmlTemplate); +}); + + +// ======================================== +// 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' }); +}); + +// ======================================== +// SERVER STARTUP +// ======================================== + +app.listen(PORT, () => { + console.log(`Analytics API server running on port ${PORT}`); + console.log(`Health check: http://localhost:${PORT}/api/health`); + console.log(`API Documentation: http://localhost:${PORT}/api-docs`); +}); + export default app; \ No newline at end of file