Skip to content

Commit bede986

Browse files
committed
feat: implement dynamic pricing system with performance optimization
- Add DynamicPricing class for market-aware price thresholds - Calculate pricing analysis in scheduled worker for better performance - Store individual fuel type thresholds in KV storage for fast lookup - Update popup generator to use dynamic price coloring - Centralize dynamic pricing configuration in constants - Maintain backward compatibility with static price fallbacks
1 parent 7c365b7 commit bede986

5 files changed

Lines changed: 223 additions & 14 deletions

File tree

src/cache-manager.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,12 @@ export class CacheManager {
187187
return `mapbox-full${limitSuffix}`;
188188
}
189189

190-
async warmPopularRegions(env: any, fullData: any): Promise<void> {
190+
async warmPopularRegions(env: any, fullData: any, priceAnalysis?: any): Promise<void> {
191191
console.log('Warming cache for popular regions...');
192192

193193
for (const region of CacheManager.POPULAR_REGIONS) {
194194
try {
195-
const features = this.filterStationsByBounds(fullData, region.bounds);
195+
const features = this.filterStationsByBounds(fullData, region.bounds, priceAnalysis);
196196
const response = {
197197
type: "FeatureCollection",
198198
features
@@ -208,7 +208,7 @@ export class CacheManager {
208208
}
209209
}
210210

211-
private filterStationsByBounds(data: any, bounds: { west: number, south: number, east: number, north: number }): any[] {
211+
private filterStationsByBounds(data: any, bounds: { west: number, south: number, east: number, north: number }, priceAnalysis?: any): any[] {
212212
const features: any[] = [];
213213

214214
for (let brand of Object.keys(data)) {
@@ -242,7 +242,8 @@ export class CacheManager {
242242
stn.address.postcode,
243243
prices.join("<br />"),
244244
false, // Cache manager doesn't calculate best prices
245-
stn.updated
245+
stn.updated,
246+
priceAnalysis
246247
),
247248
"fuel_prices": PopupGenerator.generateStructuredPrices(prices.join("<br />")),
248249
"updated": stn.updated

src/constants.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,20 @@ export const MAP_CONFIG = {
6969
MIN_ZOOM_DESKTOP: 4, // Minimum zoom level for desktop
7070
MAX_ZOOM_MOBILE: 14, // Maximum zoom level for mobile
7171
MAX_ZOOM_DESKTOP: 18 // Maximum zoom level for desktop
72+
} as const;
73+
74+
// Dynamic pricing configuration
75+
export const DYNAMIC_PRICING = {
76+
THRESHOLD_MARGIN: 0.05, // 5p difference for good/high price thresholds
77+
MIN_SAMPLE_SIZE: 10, // Minimum stations needed for reliable analysis
78+
OUTLIER_PERCENTILE: 0.1, // Remove top/bottom 10% as outliers
79+
MIN_PRICE: 0.5, // Minimum valid price in pounds
80+
MAX_PRICE: 5.0, // Maximum valid price in pounds
81+
PENCE_TO_POUNDS_THRESHOLD: 10 // Prices above this are assumed to be in pence
82+
} as const;
83+
84+
// Static price fallback thresholds
85+
export const STATIC_PRICE_FALLBACK = {
86+
LOW: 1.40, // Static fallback for low prices
87+
MEDIUM: 1.50 // Static fallback for medium prices
7288
} as const;

src/dynamic-pricing.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Dynamic pricing analysis based on current market data
3+
* Calculates price thresholds from actual fuel station prices
4+
*/
5+
import { FuelCategorizer } from './fuel-categorizer'
6+
import { DYNAMIC_PRICING, STATIC_PRICE_FALLBACK } from './constants'
7+
8+
export interface PriceThresholds {
9+
low: number; // Good deal threshold (green)
10+
high: number; // High price threshold (red)
11+
average: number; // Market average
12+
sampleSize: number; // Number of stations used in calculation
13+
}
14+
15+
export interface FuelPriceAnalysis {
16+
unleaded?: PriceThresholds;
17+
diesel?: PriceThresholds;
18+
premium?: PriceThresholds;
19+
}
20+
21+
export class DynamicPricing {
22+
23+
/**
24+
* Analyze all fuel prices and calculate dynamic thresholds for each fuel type
25+
*/
26+
static analyzePrices(fuelData: any): FuelPriceAnalysis {
27+
const pricesByFuelType = this.collectPricesByFuelType(fuelData);
28+
const analysis: FuelPriceAnalysis = {};
29+
30+
for (const [fuelType, prices] of Object.entries(pricesByFuelType)) {
31+
if (prices.length >= DYNAMIC_PRICING.MIN_SAMPLE_SIZE) {
32+
const thresholds = this.calculateThresholds(prices);
33+
analysis[fuelType as keyof FuelPriceAnalysis] = thresholds;
34+
35+
console.log(`${fuelType} analysis: avg £${thresholds.average.toFixed(2)}, ` +
36+
`good ≤£${thresholds.low.toFixed(2)}, high ≥£${thresholds.high.toFixed(2)} ` +
37+
`(${thresholds.sampleSize} stations)`);
38+
} else {
39+
console.log(`Insufficient data for ${fuelType}: only ${prices.length} stations`);
40+
}
41+
}
42+
43+
return analysis;
44+
}
45+
46+
/**
47+
* Collect and normalize all prices by fuel type from the raw data
48+
*/
49+
private static collectPricesByFuelType(fuelData: any): Record<string, number[]> {
50+
const pricesByType: Record<string, number[]> = {};
51+
52+
for (const brand of Object.values(fuelData)) {
53+
for (const station of Object.values(brand as any)) {
54+
const stationData = station as any;
55+
if (!stationData.prices) continue;
56+
57+
for (const [fuelName, price] of Object.entries(stationData.prices)) {
58+
if (typeof price !== 'number') continue;
59+
60+
// Convert to pounds and validate
61+
const priceInPounds = price > DYNAMIC_PRICING.PENCE_TO_POUNDS_THRESHOLD ? price / 100 : price;
62+
if (priceInPounds < DYNAMIC_PRICING.MIN_PRICE || priceInPounds > DYNAMIC_PRICING.MAX_PRICE) continue;
63+
64+
// Categorize and store
65+
const category = FuelCategorizer.categorizeFuelType(fuelName);
66+
if (category) {
67+
if (!pricesByType[category.name]) {
68+
pricesByType[category.name] = [];
69+
}
70+
pricesByType[category.name].push(priceInPounds);
71+
}
72+
}
73+
}
74+
}
75+
76+
return pricesByType;
77+
}
78+
79+
/**
80+
* Calculate price thresholds for a given set of prices
81+
*/
82+
private static calculateThresholds(prices: number[]): PriceThresholds {
83+
const cleanPrices = this.removeOutliers(prices);
84+
const average = cleanPrices.reduce((a, b) => a + b, 0) / cleanPrices.length;
85+
86+
return {
87+
low: average - DYNAMIC_PRICING.THRESHOLD_MARGIN,
88+
high: average + DYNAMIC_PRICING.THRESHOLD_MARGIN,
89+
average,
90+
sampleSize: cleanPrices.length
91+
};
92+
}
93+
94+
/**
95+
* Remove outliers by excluding extreme values (top and bottom 10%)
96+
*/
97+
private static removeOutliers(prices: number[]): number[] {
98+
if (prices.length < DYNAMIC_PRICING.MIN_SAMPLE_SIZE) return prices;
99+
100+
const sorted = [...prices].sort((a, b) => a - b);
101+
const removeCount = Math.floor(sorted.length * DYNAMIC_PRICING.OUTLIER_PERCENTILE);
102+
103+
return sorted.slice(removeCount, -removeCount || undefined);
104+
}
105+
106+
107+
/**
108+
* Get price category for a given fuel type and price
109+
*/
110+
static getPriceCategory(analysis: FuelPriceAnalysis, fuelType: string, price: number): 'low' | 'medium' | 'high' {
111+
const thresholds = analysis[fuelType as keyof FuelPriceAnalysis];
112+
113+
if (!thresholds) {
114+
// Fallback to static thresholds
115+
return price < STATIC_PRICE_FALLBACK.LOW ? 'low' : price < STATIC_PRICE_FALLBACK.MEDIUM ? 'medium' : 'high';
116+
}
117+
118+
return price <= thresholds.low ? 'low' : price >= thresholds.high ? 'high' : 'medium';
119+
}
120+
121+
/**
122+
* Get price color for display based on dynamic analysis
123+
*/
124+
static getPriceColor(analysis: FuelPriceAnalysis, fuelType: string, price: number): string {
125+
const colors = { low: '#00C851', medium: '#ffbb33', high: '#FF4444' };
126+
return colors[this.getPriceCategory(analysis, fuelType, price)] || '#666666';
127+
}
128+
}

src/index.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FuelCategorizer } from './fuel-categorizer'
77
import { BrandStandardizer } from './brand-standardizer'
88
import { PopupGenerator } from './popup-generator'
99
import { GeographicFilter } from './geographic-filter'
10+
import { DynamicPricing } from './dynamic-pricing'
1011
import { CACHE_TTL, STATION_LIMITS, PRICE_THRESHOLDS } from './constants'
1112

1213
const router = AutoRouter()
@@ -28,6 +29,28 @@ function getCacheTTL(env: any) {
2829
};
2930
}
3031

32+
/**
33+
* Get cached price thresholds from KV storage
34+
*/
35+
async function getCachedPriceThresholds(env: any) {
36+
const analysis: any = {};
37+
38+
// Retrieve cached thresholds for each fuel type
39+
const fuelTypes = ['unleaded', 'diesel', 'premium'];
40+
for (const fuelType of fuelTypes) {
41+
const cached = await env.KV.get(`price-threshold-${fuelType}`);
42+
if (cached) {
43+
try {
44+
analysis[fuelType] = JSON.parse(cached);
45+
} catch (error) {
46+
console.log(`Error parsing cached thresholds for ${fuelType}:`, error);
47+
}
48+
}
49+
}
50+
51+
return analysis;
52+
}
53+
3154
async function doSchedule(_event: any, env: any) {
3255
// Smart cache invalidation before update
3356
const invalidationResult = await CacheInvalidator.smartInvalidation(env, 'scheduled-update');
@@ -40,12 +63,23 @@ async function doSchedule(_event: any, env: any) {
4063
// Store main data
4164
await env.KV.put('fueldata', JSON.stringify(data))
4265

66+
// Calculate and store dynamic price thresholds for better API performance
67+
console.log('Calculating dynamic price thresholds...');
68+
const priceAnalysis = DynamicPricing.analyzePrices(data);
69+
70+
// Store individual threshold values for quick lookup
71+
for (const [fuelType, thresholds] of Object.entries(priceAnalysis)) {
72+
if (thresholds) {
73+
await env.KV.put(`price-threshold-${fuelType}`, JSON.stringify(thresholds), { expirationTtl: CACHE_TTL.FUEL_DATA });
74+
}
75+
}
76+
4377
// Update cache timestamp for smart invalidation
4478
await env.KV.put('fueldata-updated', Date.now().toString(), { expirationTtl: CACHE_TTL.FUEL_DATA });
4579

46-
// Warm cache for popular regions
80+
// Warm cache for popular regions using pre-calculated analysis
4781
const cacheManager = new CacheManager();
48-
await cacheManager.warmPopularRegions(env, data);
82+
await cacheManager.warmPopularRegions(env, data, priceAnalysis);
4983

5084
// Clean up stale cache entries
5185
const cleanupResult = await CacheInvalidator.smartInvalidation(env, 'cleanup');
@@ -148,6 +182,9 @@ router.get('/api/data.mapbox', async (request, env, _context) => {
148182
await env.KV.put("fueldata", JSON.stringify(d), { expirationTtl: CACHE_TTL.BASE_DATA })
149183
}
150184

185+
// Get cached price thresholds for better performance
186+
const priceAnalysis = await getCachedPriceThresholds(env);
187+
151188
// First pass: collect all valid stations with price data
152189
const validStations: any[] = [];
153190
let stationCount = 0;
@@ -298,13 +335,14 @@ router.get('/api/data.mapbox', async (request, env, _context) => {
298335
const priceDescription = station.prices.join("<br />");
299336
const location = station.stn.address.postcode;
300337

301-
// Generate server-side popup HTML
338+
// Generate server-side popup HTML with dynamic pricing
302339
const popupHTML = PopupGenerator.generatePopupHTML(
303340
standardizedBrand,
304341
location,
305342
priceDescription,
306343
isBestPrice,
307-
station.stn.updated
344+
station.stn.updated,
345+
priceAnalysis
308346
);
309347

310348
// Generate structured price data

src/popup-generator.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
* Server-side popup HTML generator for fuel stations
33
*/
44
import { PRICE_THRESHOLDS } from './constants'
5+
import { DynamicPricing, FuelPriceAnalysis } from './dynamic-pricing'
56
export class PopupGenerator {
67
/**
78
* Generate complete popup HTML for a fuel station
89
*/
9-
static generatePopupHTML(brand: string, location: string, priceDescription: string, isBestPrice: boolean = false, updatedTime?: string): string {
10-
const priceItems = this.parsePriceDescription(priceDescription);
10+
static generatePopupHTML(brand: string, location: string, priceDescription: string, isBestPrice: boolean = false, updatedTime?: string, priceAnalysis?: FuelPriceAnalysis): string {
11+
const priceItems = this.parsePriceDescription(priceDescription, priceAnalysis);
1112

1213
return `
1314
<div style="
@@ -67,7 +68,7 @@ export class PopupGenerator {
6768
/**
6869
* Parse price description and generate HTML for each fuel type
6970
*/
70-
private static parsePriceDescription(description: string): string[] {
71+
private static parsePriceDescription(description: string, priceAnalysis?: FuelPriceAnalysis): string[] {
7172
if (!description) return [];
7273

7374
const prices = description.split('<br />');
@@ -83,7 +84,7 @@ export class PopupGenerator {
8384
const priceVal = parseFloat(match[3]);
8485

8586
if (!isNaN(priceVal)) {
86-
const color = this.getPriceColor(priceVal);
87+
const color = this.getPriceColorDynamic(fuel, priceVal, priceAnalysis);
8788

8889
priceItems.push(`
8990
<div style="
@@ -124,14 +125,39 @@ export class PopupGenerator {
124125
}
125126

126127
/**
127-
* Get color based on price value using defined thresholds
128+
* Get color based on price value using dynamic analysis or fallback to static thresholds
129+
*/
130+
private static getPriceColorDynamic(fuelType: string, price: number, priceAnalysis?: FuelPriceAnalysis): string {
131+
if (priceAnalysis) {
132+
// Use dynamic pricing analysis if available
133+
const fuelCategory = this.mapFuelToCategory(fuelType);
134+
return DynamicPricing.getPriceColor(priceAnalysis, fuelCategory, price);
135+
}
136+
137+
// Fallback to static thresholds
138+
return this.getPriceColor(price);
139+
}
140+
141+
/**
142+
* Get color based on price value using static thresholds (fallback)
128143
*/
129144
private static getPriceColor(price: number): string {
130145
if (price < PRICE_THRESHOLDS.LOW) return '#00C851'; // Green - good price
131146
if (price < PRICE_THRESHOLDS.MEDIUM) return '#ffbb33'; // Amber - average price
132147
return '#FF4444'; // Red - high price
133148
}
134149

150+
/**
151+
* Map fuel display name to category for dynamic pricing
152+
*/
153+
private static mapFuelToCategory(fuelDisplayName: string): string {
154+
const normalized = fuelDisplayName.toLowerCase();
155+
if (/unleaded|petrol|e5|e10/.test(normalized)) return 'unleaded';
156+
if (/diesel|gasoil|b7/.test(normalized)) return 'diesel';
157+
if (/premium|super|v-power|momentum|ultimate/.test(normalized)) return 'premium';
158+
return 'unleaded'; // Default fallback
159+
}
160+
135161
/**
136162
* Generate structured fuel price data
137163
*/
@@ -166,7 +192,7 @@ export class PopupGenerator {
166192
type: fuel.toLowerCase().replace(/\s+/g, '_'),
167193
icon: icon,
168194
price: priceVal,
169-
color: this.getPriceColor(priceVal),
195+
color: this.getPriceColorDynamic(fuel, priceVal), // Note: no analysis passed here for backward compatibility
170196
displayName: fuel
171197
});
172198
}

0 commit comments

Comments
 (0)