-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathanalytics.ts
More file actions
559 lines (496 loc) · 17.3 KB
/
analytics.ts
File metadata and controls
559 lines (496 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
/**
* Analytics Module - Business Intelligence Functions
*
* This module contains pure functions for extracting actionable insights
* from gas price data. All functions are deterministic, side-effect free,
* and designed for testability.
*
* Philosophy: Transform raw data into VALUE METRICS, not just statistics.
*/
import { GasStation, CityMetrics, hasValidPrice, MarketVolatility } from '../types/gas';
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Rounds a number to a specified number of decimal places.
* Used for monetary values to ensure consistent precision.
*
* @param value - Number to round
* @param decimals - Number of decimal places (default: 2 for currency)
* @returns Rounded number
*
* @example
* ```typescript
* roundToDecimals(115.456, 2) // 115.46
* roundToDecimals(115.454, 2) // 115.45
* ```
*/
function roundToDecimals(value: number, decimals: number = 2): number {
const multiplier = Math.pow(10, decimals);
return Math.round(value * multiplier) / multiplier;
}
/**
* Normalizes a string for case-insensitive comparison.
* Removes extra whitespace and converts to lowercase.
*
* @param str - String to normalize
* @returns Normalized lowercase string
*
* @example
* ```typescript
* normalizeString(' São Paulo ') // 'são paulo'
* normalizeString('CAMPO GRANDE') // 'campo grande'
* ```
*/
function normalizeString(str: string): string {
return str.trim().toLowerCase();
}
// ============================================================================
// STATISTICAL FUNCTIONS
// ============================================================================
/**
* Calculates the standard deviation of a set of numbers.
*
* Standard deviation measures price volatility - how much prices vary
* from the average. Higher values indicate more price dispersion.
*
* @param values - Array of numbers
* @returns Standard deviation rounded to 2 decimal places
*
* @example
* ```typescript
* calculateStandardDeviation([100, 110, 120, 130]) // ~11.18
* calculateStandardDeviation([115, 115, 115]) // 0.00
* ```
*/
function calculateStandardDeviation(values: number[]): number {
if (values.length === 0) return 0;
if (values.length === 1) return 0;
const mean = values.reduce((sum, val) => sum + val, 0) / values.length;
const squaredDifferences = values.map((val) => Math.pow(val - mean, 2));
const variance =
squaredDifferences.reduce((sum, val) => sum + val, 0) / values.length;
const stdDev = Math.sqrt(variance);
return roundToDecimals(stdDev, 2);
}
/**
* Calculates the median (50th percentile) of a sorted array.
*
* @param sortedValues - Array of numbers sorted in ascending order
* @returns Median value rounded to 2 decimal places
*
* @example
* ```typescript
* calculateMedian([100, 110, 120]) // 110.00
* calculateMedian([100, 110, 120, 130]) // 115.00 (average of middle two)
* ```
*/
function calculateMedian(sortedValues: number[]): number {
if (sortedValues.length === 0) return 0;
if (sortedValues.length === 1) return sortedValues[0] ?? 0;
const middle = Math.floor(sortedValues.length / 2);
// If odd length, return middle element
if (sortedValues.length % 2 === 1) {
return roundToDecimals(sortedValues[middle] ?? 0, 2);
}
// If even length, return average of two middle elements
const middleValue1 = sortedValues[middle - 1] ?? 0;
const middleValue2 = sortedValues[middle] ?? 0;
return roundToDecimals((middleValue1 + middleValue2) / 2, 2);
}
/**
* Calculates quartiles (Q1, Q2, Q3) from a sorted array of values.
*
* Quartiles divide the dataset into four equal parts:
* - Q1 (25th percentile): 25% of values are below this
* - Q2 (50th percentile): Median - 50% of values are below this
* - Q3 (75th percentile): 75% of values are below this
*
* @param sortedValues - Array of numbers sorted in ascending order
* @returns Object with q1, q2, q3 values
*
* @example
* ```typescript
* const prices = [95, 100, 105, 110, 115, 120, 125];
* calculateQuartiles(prices)
* // { q1: 100, q2: 110, q3: 120 }
* ```
*/
function calculateQuartiles(sortedValues: number[]): { q1: number; q2: number; q3: number } {
if (sortedValues.length === 0) {
return { q1: 0, q2: 0, q3: 0 };
}
if (sortedValues.length === 1) {
const value = sortedValues[0] ?? 0;
return { q1: value, q2: value, q3: value };
}
// Q2 is the median
const q2 = calculateMedian(sortedValues);
// Q1 is the median of the lower half
const lowerHalfEnd = Math.floor(sortedValues.length / 2);
const lowerHalf = sortedValues.slice(0, lowerHalfEnd);
const q1 = calculateMedian(lowerHalf);
// Q3 is the median of the upper half
const upperHalfStart = Math.ceil(sortedValues.length / 2);
const upperHalf = sortedValues.slice(upperHalfStart);
const q3 = calculateMedian(upperHalf);
return {
q1: roundToDecimals(q1, 2),
q2: roundToDecimals(q2, 2),
q3: roundToDecimals(q3, 2),
};
}
/**
* Classifies market volatility based on price range percentage.
*
* This qualitative classification helps users understand market stability
* at a glance without needing to interpret statistical measures.
*
* Classification Rules:
* - Baixa: < 10% variation (stable market, prices are similar)
* - Moderada: 10-20% variation (normal competition)
* - Significativa: 20-30% variation (high competition or price war)
* - Alta: 30-40% variation (unstable market)
* - Muito Alta: > 40% variation (extreme price dispersion)
*
* @param priceRangePercentage - Price range as percentage of minimum price
* @returns Qualitative volatility classification
*
* @example
* ```typescript
* classifyVolatility(8.5) // 'Baixa'
* classifyVolatility(15.0) // 'Moderada'
* classifyVolatility(25.0) // 'Significativa'
* classifyVolatility(35.0) // 'Alta'
* classifyVolatility(45.0) // 'Muito Alta'
* ```
*/
function classifyVolatility(priceRangePercentage: number): MarketVolatility {
if (priceRangePercentage < 10) {
return 'Baixa';
} else if (priceRangePercentage < 20) {
return 'Moderada';
} else if (priceRangePercentage < 30) {
return 'Significativa';
} else if (priceRangePercentage < 40) {
return 'Alta';
} else {
return 'Muito Alta';
}
}
// ============================================================================
// CORE ANALYTICS FUNCTIONS
// ============================================================================
/**
* Calculates comprehensive pricing metrics for a specific city.
*
* This is the core VALUE METRIC function that transforms raw gas station
* data into actionable insights for end users.
*
* Key Metric: **savingsGap** - Shows how much money users can save by
* choosing the cheapest option instead of the most expensive one.
*
* @param stations - Array of all gas stations with pricing data
* @param city - City name to analyze (case insensitive)
* @returns CityMetrics object with calculated KPIs, or null if no data
*
* @throws {TypeError} If stations is not an array
* @throws {TypeError} If city is not a string
*
* @example
* ```typescript
* const metrics = calculateCityMetrics(allStations, 'São Paulo');
*
* if (metrics) {
* console.log(`Você pode economizar até R$ ${metrics.savingsGap}`);
* console.log(`Menor preço: R$ ${metrics.minPrice}`);
* console.log(`Maior preço: R$ ${metrics.maxPrice}`);
* }
* ```
*
* @remarks
* - Returns null if city has no stations or all prices are invalid
* - All monetary values are rounded to 2 decimal places
* - Case-insensitive city matching for better UX
* - Filters out stations with invalid prices (NaN, 0, negative, Infinity)
*/
export function calculateCityMetrics(
stations: GasStation[],
city: string
): CityMetrics | null {
// Input validation
if (!Array.isArray(stations)) {
throw new TypeError('stations must be an array');
}
if (typeof city !== 'string') {
throw new TypeError('city must be a string');
}
// Handle empty inputs
if (stations.length === 0 || city.trim() === '') {
return null;
}
// Normalize city name for case-insensitive comparison
const normalizedCity = normalizeString(city);
// Filter stations by city (case insensitive) and validate prices
const cityStations = stations.filter((station) => {
if (!station || typeof station !== 'object') {
return false;
}
const stationCityNormalized = normalizeString(station.city);
return stationCityNormalized === normalizedCity && hasValidPrice(station);
});
// Return null if no valid stations found for this city
if (cityStations.length === 0) {
return null;
}
// Extract valid prices and sort for quartile calculation
const prices = cityStations.map((station) => station.price);
const sortedPrices = [...prices].sort((a, b) => a - b);
// Calculate min, max, and average prices
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const sumPrices = prices.reduce((acc, price) => acc + price, 0);
const averagePrice = sumPrices / prices.length;
// Calculate median (more robust than average for skewed distributions)
const medianPrice = calculateMedian(sortedPrices);
/**
* SAVINGS GAP - The Most Important Metric
*
* This represents the maximum amount a user can save by choosing
* the cheapest gas station instead of the most expensive one.
*
* This is a VALUE METRIC that directly answers:
* "How much money am I leaving on the table?"
*/
const savingsGap = maxPrice - minPrice;
/**
* SAVINGS PERCENTAGE - Relative Savings Context
*
* Shows savings as a percentage of the maximum price.
* Makes it easier to understand: "You can save 24% by choosing wisely"
*/
const savingsPercentage = maxPrice > 0 ? (savingsGap / maxPrice) * 100 : 0;
/**
* PRICE RANGE PERCENTAGE - Market Volatility Indicator
*
* Shows how much prices vary relative to the cheapest option.
* High values indicate unstable/competitive market.
*/
const priceRangePercentage = minPrice > 0 ? (savingsGap / minPrice) * 100 : 0;
// Calculate standard deviation (price volatility)
const standardDeviation = calculateStandardDeviation(prices);
// Calculate quartiles for distribution analysis
const quartiles = calculateQuartiles(sortedPrices);
// Find the most recent update date from all city stations
const lastUpdate = cityStations.reduce((latest, station) => {
return station.lastUpdate > latest ? station.lastUpdate : latest;
}, cityStations[0]?.lastUpdate ?? new Date());
// Classify market volatility
const volatility = classifyVolatility(priceRangePercentage);
// Return metrics with all monetary values rounded to 2 decimal places
return {
// Basic Statistics
minPrice: roundToDecimals(minPrice, 2),
maxPrice: roundToDecimals(maxPrice, 2),
averagePrice: roundToDecimals(averagePrice, 2),
// Advanced Statistics (BI)
medianPrice: roundToDecimals(medianPrice, 2),
q1: quartiles.q1,
q3: quartiles.q3,
// Business KPIs
savingsPotential: roundToDecimals(savingsGap, 2),
savingsGap: roundToDecimals(savingsGap, 2), // Backward compatibility
savingsPercentage: roundToDecimals(savingsPercentage, 2),
volatility,
// Supporting Metrics
standardDeviation: roundToDecimals(standardDeviation, 2),
priceRangePercentage: roundToDecimals(priceRangePercentage, 2),
// Metadata
stationsCount: cityStations.length,
lastScan: lastUpdate,
lastUpdate, // Backward compatibility
// Legacy structure (backward compatibility)
quartiles,
};
}
// ============================================================================
// ADDITIONAL ANALYTICS FUNCTIONS (Future Expansion)
// ============================================================================
/**
* Finds the cheapest gas station in a city.
*
* @param stations - Array of gas stations
* @param city - City name
* @returns Gas station with the lowest price, or null if none found
*
* @example
* ```typescript
* const bestDeal = findCheapestStation(stations, 'Rio de Janeiro');
* if (bestDeal) {
* console.log(`Melhor preço: ${bestDeal.name} - R$ ${bestDeal.price}`);
* }
* ```
*/
export function findCheapestStation(
stations: GasStation[],
city: string
): GasStation | null {
const normalizedCity = normalizeString(city);
const cityStations = stations.filter((station) => {
const stationCityNormalized = normalizeString(station.city);
return stationCityNormalized === normalizedCity && hasValidPrice(station);
});
if (cityStations.length === 0) {
return null;
}
return cityStations.reduce((cheapest, station) => {
return station.price < cheapest.price ? station : cheapest;
});
}
/**
* Finds the most expensive gas station in a city.
*
* @param stations - Array of gas stations
* @param city - City name
* @returns Gas station with the highest price, or null if none found
*/
export function findMostExpensiveStation(
stations: GasStation[],
city: string
): GasStation | null {
const normalizedCity = normalizeString(city);
const cityStations = stations.filter((station) => {
const stationCityNormalized = normalizeString(station.city);
return stationCityNormalized === normalizedCity && hasValidPrice(station);
});
if (cityStations.length === 0) {
return null;
}
return cityStations.reduce((mostExpensive, station) => {
return station.price > mostExpensive.price ? station : mostExpensive;
});
}
/**
* Gets the top N cheapest gas stations in a city.
*
* @param stations - Array of gas stations
* @param city - City name
* @param limit - Number of stations to return (default: 5)
* @returns Array of cheapest stations, sorted by price (ascending)
*
* @example
* ```typescript
* const top5 = getTopCheapestStations(stations, 'São Paulo', 5);
* top5.forEach((station, index) => {
* console.log(`${index + 1}. ${station.name} - R$ ${station.price}`);
* });
* ```
*/
export function getTopCheapestStations(
stations: GasStation[],
city: string,
limit: number = 5
): GasStation[] {
const normalizedCity = normalizeString(city);
const cityStations = stations.filter((station) => {
const stationCityNormalized = normalizeString(station.city);
return stationCityNormalized === normalizedCity && hasValidPrice(station);
});
// Sort by price (ascending) and return top N
return cityStations.sort((a, b) => a.price - b.price).slice(0, limit);
}
/**
* Calculates the percentage a specific price is above/below the city average.
*
* @param price - Price to compare
* @param cityMetrics - City metrics containing the average price
* @returns Percentage difference (positive = above average, negative = below)
*
* @example
* ```typescript
* const metrics = calculateCityMetrics(stations, 'Campinas');
* const variance = calculatePriceVariance(120.00, metrics);
* // If average is 110.00: variance = 9.09 (9.09% above average)
* ```
*/
export function calculatePriceVariance(
price: number,
cityMetrics: CityMetrics
): number {
const variance =
((price - cityMetrics.averagePrice) / cityMetrics.averagePrice) * 100;
return roundToDecimals(variance, 2);
}
/**
* Estimates annual savings potential for a user.
*
* Assumes a user buys one 13kg GLP cylinder per month.
*
* @param currentPrice - Price user currently pays
* @param cityMetrics - City metrics containing the minimum price
* @returns Estimated annual savings in BRL
*
* @example
* ```typescript
* const metrics = calculateCityMetrics(stations, 'Brasília');
* const savings = calculateAnnualSavings(120.00, metrics);
* // If min price is 105.00: savings = 180.00 (R$ 15/month * 12 months)
* console.log(`Economia anual: R$ ${savings.toFixed(2)}`);
* ```
*/
export function calculateAnnualSavings(
currentPrice: number,
cityMetrics: CityMetrics
): number {
const monthlySavings = currentPrice - cityMetrics.minPrice;
const annualSavings = monthlySavings * 12;
return roundToDecimals(Math.max(0, annualSavings), 2);
}
/**
* Groups stations by brand/flag and calculates average price per brand.
*
* @param stations - Array of gas stations
* @param city - Optional city filter
* @returns Map of brand names to average prices
*
* @example
* ```typescript
* const brandPrices = calculateBrandAverages(stations, 'Porto Alegre');
* brandPrices.forEach((avgPrice, brand) => {
* console.log(`${brand}: R$ ${avgPrice.toFixed(2)}`);
* });
* ```
*/
export function calculateBrandAverages(
stations: GasStation[],
city?: string
): Map<string, number> {
let filteredStations = stations.filter(hasValidPrice);
// Apply city filter if provided
if (city) {
const normalizedCity = normalizeString(city);
filteredStations = filteredStations.filter((station) => {
return normalizeString(station.city) === normalizedCity;
});
}
// Group by brand
const brandGroups = new Map<string, number[]>();
filteredStations.forEach((station) => {
const brand = station.flag;
if (!brandGroups.has(brand)) {
brandGroups.set(brand, []);
}
const brandPrices = brandGroups.get(brand);
if (brandPrices) {
brandPrices.push(station.price);
}
});
// Calculate average for each brand
const brandAverages = new Map<string, number>();
brandGroups.forEach((prices, brand) => {
const sum = prices.reduce((acc, price) => acc + price, 0);
const average = sum / prices.length;
brandAverages.set(brand, roundToDecimals(average, 2));
});
return brandAverages;
}