From 93d192a995239968fd49a4dbcde2ed7fd4a717f0 Mon Sep 17 00:00:00 2001 From: raymond Date: Tue, 2 Sep 2025 04:32:29 +0000 Subject: [PATCH] add kmeans, moving-average, temporal functions, retail functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add 1. "ml/kmeans" (kmeans.ts) 2. "series/moving-average" (time-helper.ts) 3. "time/week-number", "time/same-day-last-year" (time-helper.ts) 4. 購買率"retail/purchase-rate", リフト値"retail/lift-value", 原価率"retail/cost-ratio" 値入り率"retail/gross-margin" (server.ts) --- kmeans.ts | 118 ++++++++ server.ts | 766 ++++++++++++++++++++++++------------------------- time-helper.ts | 24 ++ 3 files changed, 524 insertions(+), 384 deletions(-) create mode 100644 kmeans.ts create mode 100644 time-helper.ts diff --git a/kmeans.ts b/kmeans.ts new file mode 100644 index 0000000..0f837c1 --- /dev/null +++ b/kmeans.ts @@ -0,0 +1,118 @@ +// kmeans.ts - K-Means clustering algorithm + +export interface Point { + x: number; + y: number; +} + +export interface Cluster { + centroid: Point; + points: Point[]; +} + +export interface KMeansResult { + clusters: Cluster[]; + iterations: number; + converged: boolean; +} + +export class KMeans { + private readonly k: number; + private readonly maxIterations: number; + private readonly data: Point[]; + private clusters: Cluster[] = []; + + constructor(data: Point[], k: number, maxIterations: number = 50) { + this.k = k; + this.maxIterations = maxIterations; + this.data = data; + } + + private static euclideanDistance(p1: Point, p2: Point): number { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return Math.sqrt(dx * dx + dy * dy); + } + + private initializeCentroids(): void { + const centroids: Point[] = []; + const dataCopy = [...this.data]; + for (let i = 0; i < this.k && dataCopy.length > 0; i++) { + const randomIndex = Math.floor(Math.random() * dataCopy.length); + const centroid = { ...dataCopy[randomIndex] }; + centroids.push(centroid); + dataCopy.splice(randomIndex, 1); + } + this.clusters = centroids.map(c => ({ centroid: c, points: [] })); + } + + private assignClusters(pointAssignments: number[]): boolean { + let hasChanged = false; + + for (const cluster of this.clusters) { + cluster.points = []; + } + + this.data.forEach((point, pointIndex) => { + let minDistance = Infinity; + let closestClusterIndex = -1; + + this.clusters.forEach((cluster, clusterIndex) => { + const distance = KMeans.euclideanDistance(point, cluster.centroid); + if (distance < minDistance) { + minDistance = distance; + closestClusterIndex = clusterIndex; + } + }); + + if (pointAssignments[pointIndex] !== closestClusterIndex) { + hasChanged = true; + pointAssignments[pointIndex] = closestClusterIndex; + } + + if (closestClusterIndex !== -1) { + this.clusters[closestClusterIndex].points.push(point); + } + }); + + return hasChanged; + } + + private updateCentroids(): void { + for (const cluster of this.clusters) { + if (cluster.points.length === 0) continue; + + const sumX = cluster.points.reduce((sum, p) => sum + p.x, 0); + const sumY = cluster.points.reduce((sum, p) => sum + p.y, 0); + + cluster.centroid.x = sumX / cluster.points.length; + cluster.centroid.y = sumY / cluster.points.length; + } + } + + public run(): KMeansResult { + this.initializeCentroids(); + + const pointAssignments = new Array(this.data.length).fill(-1); + + let iterations = 0; + let converged = false; + + for (let i = 0; i < this.maxIterations; i++) { + iterations = i + 1; + const hasChanged = this.assignClusters(pointAssignments); + this.updateCentroids(); + + if (!hasChanged) { + converged = true; + break; + } + } + + return { + clusters: this.clusters, + iterations, + converged + }; + } +} \ No newline at end of file diff --git a/server.ts b/server.ts index 52b9fae..99769ec 100644 --- a/server.ts +++ b/server.ts @@ -1,385 +1,383 @@ -// package.json dependencies needed: -// npm install express mathjs lodash -// 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'; - -const app = express(); -app.use(express.json()); - -// Types for our data structures -interface DataSeries { - values: number[]; - labels?: string[]; -} - -interface Condition { - field: string; - operator: '>' | '<' | '=' | '>=' | '<=' | '!='; - value: number | string; -} - -interface ApiResponse { - success: boolean; - data?: T; - error?: string; -} - -// Helper function for error handling -const handleError = (error: unknown): string => { - return error instanceof Error ? error.message : 'Unknown error'; -}; - -// Core statistical functions -class AnalyticsEngine { - - // Apply conditions to filter data - private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] { - if (conditions.length === 0) return series.values; - - // For now, just return all values - you'd implement condition logic here - // This would involve checking conditions against associated metadata - return series.values; - } - - // Remove duplicates from series - unique(series: DataSeries): number[] { - return _.uniq(series.values); - } - - // Calculate mean with optional conditions - mean(series: DataSeries, conditions: Condition[] = []): number { - const filteredValues = this.applyConditions(series, conditions); - if (filteredValues.length === 0) throw new Error('No data points match conditions'); - - return Number(math.mean(filteredValues)); - } - - // Count values with optional conditions - count(series: DataSeries, conditions: Condition[] = []): number { - const filteredValues = this.applyConditions(series, conditions); - return filteredValues.length; - } - - // Calculate variance - variance(series: DataSeries, conditions: Condition[] = []): number { - const filteredValues = this.applyConditions(series, conditions); - if (filteredValues.length === 0) throw new Error('No data points match conditions'); - - return Number(math.variance(filteredValues)); - } - - // Calculate standard deviation - standardDeviation(series: DataSeries, conditions: Condition[] = []): number { - const filteredValues = this.applyConditions(series, conditions); - if (filteredValues.length === 0) throw new Error('No data points match conditions'); - - return Number(math.std(filteredValues)); - } - - // Calculate percentile/quantile - percentile( - series: DataSeries, - percent: number, - ascending: boolean = true, - conditions: Condition[] = [] - ): number { - 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; - } - - // Calculate median (50th percentile) - median(series: DataSeries, conditions: Condition[] = []): number { - return this.percentile(series, 50, true, conditions); - } - - // Calculate mode (most frequent value) - mode(series: DataSeries, conditions: Condition[] = []): number[] { - 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); - } - - // Rank values and get top N - topN( - series: DataSeries, - n: number, - ascending: boolean = false, - conditions: Condition[] = [] - ): number[] { - const filteredValues = this.applyConditions(series, conditions); - const sorted = ascending ? - _.sortBy(filteredValues) : - _.sortBy(filteredValues).reverse(); - - return sorted.slice(0, n); - } - - // Get maximum value - max(series: DataSeries, conditions: Condition[] = []): number { - const filteredValues = this.applyConditions(series, conditions); - if (filteredValues.length === 0) throw new Error('No data points match conditions'); - - return Math.max(...filteredValues); - } - - // Get minimum value - min(series: DataSeries, conditions: Condition[] = []): number { - const filteredValues = this.applyConditions(series, conditions); - if (filteredValues.length === 0) throw new Error('No data points match conditions'); - - return Math.min(...filteredValues); - } - - // Calculate percent change - percentChange(series: DataSeries, step: number = 1): number[] { - const values = series.values; - const changes: number[] = []; - - for (let i = step; i < values.length; i++) { - const change = ((values[i] - values[i - step]) / values[i - step]) * 100; - changes.push(change); - } - - return changes; - } - - // Basic correlation between two series - correlation(series1: DataSeries, series2: DataSeries): number { - 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; - } -} - -// Initialize analytics engine -const analytics = new AnalyticsEngine(); - -// API Routes -app.get('/api/health', (req, res) => { - res.json({ status: 'OK', timestamp: new Date().toISOString() }); -}); - -// Unique values endpoint -app.post('/api/unique', (req, res) => { - try { - const { series }: { series: DataSeries } = req.body; - const result = analytics.unique(series); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Mean calculation endpoint -app.post('/api/mean', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.mean(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Count endpoint -app.post('/api/count', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.count(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Variance endpoint -app.post('/api/variance', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.variance(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Standard deviation endpoint -app.post('/api/std', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.standardDeviation(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Percentile endpoint -app.post('/api/percentile', (req, res) => { - try { - const { - series, - percent, - ascending = true, - conditions = [] - }: { - series: DataSeries; - percent: number; - ascending?: boolean; - conditions?: Condition[] - } = req.body; - - const result = analytics.percentile(series, percent, ascending, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Median endpoint -app.post('/api/median', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.median(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Mode endpoint -app.post('/api/mode', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.mode(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Top N endpoint -app.post('/api/topn', (req, res) => { - try { - const { - series, - n, - ascending = false, - conditions = [] - }: { - series: DataSeries; - n: number; - ascending?: boolean; - conditions?: Condition[] - } = req.body; - - const result = analytics.topN(series, n, ascending, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Max/Min endpoints -app.post('/api/max', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.max(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -app.post('/api/min', (req, res) => { - try { - const { series, conditions = [] }: { series: DataSeries; conditions?: Condition[] } = req.body; - const result = analytics.min(series, conditions); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Percent change endpoint -app.post('/api/percent-change', (req, res) => { - try { - const { series, step = 1 }: { series: DataSeries; step?: number } = req.body; - const result = analytics.percentChange(series, step); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Correlation endpoint -app.post('/api/correlation', (req, res) => { - try { - const { series1, series2 }: { series1: DataSeries; series2: DataSeries } = req.body; - const result = analytics.correlation(series1, series2); - res.json({ success: true, data: result } as ApiResponse); - } catch (error) { - const errorMessage = handleError(error); - res.status(400).json({ success: false, error: errorMessage } as ApiResponse); - } -}); - -// Error handling middleware -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); -}); - -// Start server -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`); -}); - +// 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, Point } 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): { clusters: number[][][], centroids: number[][] } { + validateMatrix(matrix); + if (matrix.data[0].length !== 2) { + throw new Error('K-means implementation currently only supports 2D data.'); + } + const points = matrix.data.map(row => ({ x: row[0], y: row[1] })); + const kmeans = new KMeans(points, nClusters); + const result = kmeans.run(); + const centroids = result.clusters.map(c => [c.centroid.x, c.centroid.y]); + const clusters = result.clusters.map(c => c.points.map(p => [p.x, p.y])); + 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; + } +} + +// 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) => analytics.kmeans(req.body.matrix, req.body.nClusters)); + +// 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)); + +// ======================================== +// 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; \ No newline at end of file diff --git a/time-helper.ts b/time-helper.ts new file mode 100644 index 0000000..b7acecc --- /dev/null +++ b/time-helper.ts @@ -0,0 +1,24 @@ +// time-helpers.ts - Date and time utility functions + +import { getISOWeek, getISODay, subYears, setISOWeek, setISODay, isValid } from 'date-fns'; + +export const getWeekNumber = (dateString: string): number => { + const date = new Date(dateString); + if (!isValid(date)) { + throw new Error('Invalid date string provided.'); + } + return getISOWeek(date); +}; + +export const getSameWeekDayLastYear = (dateString: string): string => { + const baseDate = new Date(dateString); + if (!isValid(baseDate)) { + throw new Error('Invalid date string provided.'); + } + const originalWeek = getISOWeek(baseDate); + const originalDayOfWeek = getISODay(baseDate); + const lastYearDate = subYears(baseDate, 1); + const dateWithWeekSet = setISOWeek(lastYearDate, originalWeek); + const finalDate = setISODay(dateWithWeekSet, originalDayOfWeek); + return finalDate.toISOString().split('T')[0]; // Return as YYYY-MM-DD +}; \ No newline at end of file