| import { getRpcBaseUrl } from '@/services/rpc-client'; |
| import type { GetConsumerPriceOverviewResponse, GetConsumerPriceBasketSeriesResponse, ListConsumerPriceCategoriesResponse, ListConsumerPriceMoversResponse, ListRetailerPriceSpreadsResponse, GetConsumerPriceFreshnessResponse, CategorySnapshot, PriceMover, RetailerSpread, BasketPoint, RetailerFreshnessInfo } from '@/generated/client/worldmonitor/consumer_prices/v1/service_client'; |
| import { createCircuitBreaker } from '@/utils'; |
| import { getHydratedData } from '@/services/bootstrap'; |
| import { ConsumerPricesServiceClient } from '@/services/generated-rpc-clients'; |
|
|
| export type { |
| GetConsumerPriceOverviewResponse, |
| GetConsumerPriceBasketSeriesResponse, |
| ListConsumerPriceCategoriesResponse, |
| ListConsumerPriceMoversResponse, |
| ListRetailerPriceSpreadsResponse, |
| GetConsumerPriceFreshnessResponse, |
| CategorySnapshot, |
| PriceMover, |
| RetailerSpread, |
| BasketPoint, |
| RetailerFreshnessInfo, |
| }; |
|
|
| export const DEFAULT_MARKET = 'all'; |
| export const DEFAULT_BASKET = 'essentials-ae'; |
|
|
| export const MARKETS: Array<{ code: string; label: string }> = [ |
| { code: 'all', label: '🌍 All' }, |
| { code: 'ae', label: '🇦🇪 UAE' }, |
| { code: 'au', label: '🇦🇺 AU' }, |
| { code: 'br', label: '🇧🇷 BR' }, |
| { code: 'gb', label: '🇬🇧 UK' }, |
| { code: 'in', label: '🇮🇳 IN' }, |
| { code: 'sa', label: '🇸🇦 SA' }, |
| { code: 'sg', label: '🇸🇬 SG' }, |
| { code: 'us', label: '🇺🇸 US' }, |
| ]; |
|
|
| export const SINGLE_MARKETS = MARKETS.filter((m) => m.code !== 'all'); |
|
|
| const client = new ConsumerPricesServiceClient(getRpcBaseUrl(), { |
| fetch: (...args) => globalThis.fetch(...args), |
| }); |
|
|
| const overviewBreaker = createCircuitBreaker<GetConsumerPriceOverviewResponse>({ |
| name: 'Consumer Prices Overview', |
| cacheTtlMs: 30 * 60 * 1000, |
| persistCache: true, |
| }); |
| const seriesBreaker = createCircuitBreaker<GetConsumerPriceBasketSeriesResponse>({ |
| name: 'Consumer Prices Series', |
| cacheTtlMs: 60 * 60 * 1000, |
| persistCache: true, |
| }); |
| const categoriesBreaker = createCircuitBreaker<ListConsumerPriceCategoriesResponse>({ |
| name: 'Consumer Prices Categories', |
| cacheTtlMs: 30 * 60 * 1000, |
| persistCache: true, |
| }); |
| const moversBreaker = createCircuitBreaker<ListConsumerPriceMoversResponse>({ |
| name: 'Consumer Prices Movers', |
| cacheTtlMs: 30 * 60 * 1000, |
| persistCache: true, |
| }); |
| const spreadBreaker = createCircuitBreaker<ListRetailerPriceSpreadsResponse>({ |
| name: 'Consumer Prices Spread', |
| cacheTtlMs: 30 * 60 * 1000, |
| persistCache: true, |
| }); |
| const freshnessBreaker = createCircuitBreaker<GetConsumerPriceFreshnessResponse>({ |
| name: 'Consumer Prices Freshness', |
| cacheTtlMs: 10 * 60 * 1000, |
| persistCache: true, |
| }); |
|
|
| const emptyOverview: GetConsumerPriceOverviewResponse = { |
| marketCode: DEFAULT_MARKET, |
| asOf: '0', |
| currencyCode: 'AED', |
| essentialsIndex: 0, |
| valueBasketIndex: 0, |
| wowPct: 0, |
| momPct: 0, |
| retailerSpreadPct: 0, |
| coveragePct: 0, |
| freshnessLagMin: 0, |
| topCategories: [], |
| upstreamUnavailable: true, |
| }; |
|
|
| const emptySeries: GetConsumerPriceBasketSeriesResponse = { |
| marketCode: DEFAULT_MARKET, |
| basketSlug: DEFAULT_BASKET, |
| asOf: '0', |
| currencyCode: 'AED', |
| range: '30d', |
| essentialsSeries: [], |
| valueSeries: [], |
| upstreamUnavailable: true, |
| }; |
|
|
| const emptyCategories: ListConsumerPriceCategoriesResponse = { |
| marketCode: DEFAULT_MARKET, |
| asOf: '0', |
| range: '30d', |
| categories: [], |
| upstreamUnavailable: true, |
| }; |
|
|
| const emptyMovers: ListConsumerPriceMoversResponse = { |
| marketCode: DEFAULT_MARKET, |
| asOf: '0', |
| range: '30d', |
| risers: [], |
| fallers: [], |
| upstreamUnavailable: true, |
| }; |
|
|
| const emptySpread: ListRetailerPriceSpreadsResponse = { |
| marketCode: DEFAULT_MARKET, |
| asOf: '0', |
| basketSlug: DEFAULT_BASKET, |
| currencyCode: 'AED', |
| retailers: [], |
| spreadPct: 0, |
| upstreamUnavailable: true, |
| }; |
|
|
| const emptyFreshness: GetConsumerPriceFreshnessResponse = { |
| marketCode: DEFAULT_MARKET, |
| asOf: '0', |
| retailers: [], |
| overallFreshnessMin: 0, |
| stalledCount: 0, |
| upstreamUnavailable: true, |
| }; |
|
|
| export async function fetchConsumerPriceOverview( |
| marketCode = DEFAULT_MARKET, |
| basketSlug = DEFAULT_BASKET, |
| ): Promise<GetConsumerPriceOverviewResponse> { |
| |
| if (marketCode === DEFAULT_MARKET) { |
| const hydrated = getHydratedData('consumerPricesOverview') as GetConsumerPriceOverviewResponse | undefined; |
| if (hydrated?.asOf && !hydrated.upstreamUnavailable) return hydrated; |
| } |
|
|
| try { |
| return await overviewBreaker.execute( |
| () => client.getConsumerPriceOverview({ marketCode, basketSlug }), |
| emptyOverview, |
| { cacheKey: `${marketCode}:${basketSlug}` }, |
| ); |
| } catch { |
| return emptyOverview; |
| } |
| } |
|
|
| export async function fetchConsumerPriceBasketSeries( |
| marketCode = DEFAULT_MARKET, |
| basketSlug = DEFAULT_BASKET, |
| range = '30d', |
| ): Promise<GetConsumerPriceBasketSeriesResponse> { |
| try { |
| return await seriesBreaker.execute( |
| () => client.getConsumerPriceBasketSeries({ marketCode, basketSlug, range }), |
| emptySeries, |
| { cacheKey: `${marketCode}:${basketSlug}:${range}` }, |
| ); |
| } catch { |
| return { ...emptySeries, range }; |
| } |
| } |
|
|
| export async function fetchConsumerPriceCategories( |
| marketCode = DEFAULT_MARKET, |
| basketSlug = DEFAULT_BASKET, |
| range = '30d', |
| ): Promise<ListConsumerPriceCategoriesResponse> { |
| if (marketCode === DEFAULT_MARKET) { |
| const hydrated = getHydratedData('consumerPricesCategories') as ListConsumerPriceCategoriesResponse | undefined; |
| if (hydrated?.categories?.length) return hydrated; |
| } |
|
|
| try { |
| return await categoriesBreaker.execute( |
| () => client.listConsumerPriceCategories({ marketCode, basketSlug, range }), |
| emptyCategories, |
| { cacheKey: `${marketCode}:${basketSlug}:${range}` }, |
| ); |
| } catch { |
| return emptyCategories; |
| } |
| } |
|
|
| export async function fetchConsumerPriceMovers( |
| marketCode = DEFAULT_MARKET, |
| range = '30d', |
| categorySlug?: string, |
| ): Promise<ListConsumerPriceMoversResponse> { |
| if (marketCode === DEFAULT_MARKET) { |
| const hydrated = getHydratedData('consumerPricesMovers') as ListConsumerPriceMoversResponse | undefined; |
| if (hydrated?.risers?.length || hydrated?.fallers?.length) return hydrated; |
| } |
|
|
| try { |
| return await moversBreaker.execute( |
| () => client.listConsumerPriceMovers({ marketCode, range, categorySlug: categorySlug ?? '', limit: 10 }), |
| emptyMovers, |
| { cacheKey: `${marketCode}:${range}:${categorySlug ?? ''}` }, |
| ); |
| } catch { |
| return emptyMovers; |
| } |
| } |
|
|
| export async function fetchRetailerPriceSpreads( |
| marketCode = DEFAULT_MARKET, |
| basketSlug = DEFAULT_BASKET, |
| ): Promise<ListRetailerPriceSpreadsResponse> { |
| if (marketCode === DEFAULT_MARKET) { |
| const hydrated = getHydratedData('consumerPricesSpread') as ListRetailerPriceSpreadsResponse | undefined; |
| if (hydrated?.retailers?.length) return hydrated; |
| } |
|
|
| try { |
| return await spreadBreaker.execute( |
| () => client.listRetailerPriceSpreads({ marketCode, basketSlug }), |
| emptySpread, |
| { cacheKey: `${marketCode}:${basketSlug}` }, |
| ); |
| } catch { |
| return emptySpread; |
| } |
| } |
|
|
| export async function fetchConsumerPriceFreshness( |
| marketCode = DEFAULT_MARKET, |
| ): Promise<GetConsumerPriceFreshnessResponse> { |
| try { |
| return await freshnessBreaker.execute( |
| () => client.getConsumerPriceFreshness({ marketCode }), |
| emptyFreshness, |
| { cacheKey: marketCode }, |
| ); |
| } catch { |
| return emptyFreshness; |
| } |
| } |
|
|
| const allMarketsBreaker = createCircuitBreaker<GetConsumerPriceOverviewResponse[]>({ |
| name: 'All Markets Overview', |
| cacheTtlMs: 30 * 60 * 1000, |
| persistCache: true, |
| }); |
|
|
| export async function fetchAllMarketsOverview(): Promise<GetConsumerPriceOverviewResponse[]> { |
| return allMarketsBreaker.execute( |
| () => Promise.all( |
| SINGLE_MARKETS.map((m) => fetchConsumerPriceOverview(m.code, `essentials-${m.code}`)), |
| ), |
| [], |
| ); |
| } |
|
|