diff --git a/backend/db.js b/backend/db.js index d1f1d09..c16c919 100644 --- a/backend/db.js +++ b/backend/db.js @@ -155,6 +155,23 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome + ON orders ((NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), ''))); + `).catch(err => { + console.error('Notice: Could not create normalized client name index:', err.message); + }); + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome_phone_date + ON orders ( + (NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')), + data_pedido_date DESC, + id DESC + ) + WHERE NULLIF(cliente_fone, '') IS NOT NULL; + `).catch(err => { + console.error('Notice: Could not create normalized client phone lookup index:', err.message); + }); await pool.query(` CREATE INDEX IF NOT EXISTS idx_orders_customer_key_date ON orders ( diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index fac19f1..295e27d 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -17,31 +17,28 @@ const PRODUCT_NAME_SQL = ` const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')"; // Phone-less historical rows must follow the later known phone for the same client name. const CUSTOMER_IDENTITY_CTE = ` - WITH order_identity AS ( + WITH customer_phone_by_name AS ( SELECT - orders.*, - ${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name - FROM orders - ), - customer_phone_by_name AS ( - SELECT - normalized_customer_name, + ${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name, (ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC) - FILTER (WHERE NULLIF(cliente_fone, '') IS NOT NULL))[1] as canonical_phone - FROM order_identity - WHERE normalized_customer_name IS NOT NULL + )[1] as canonical_phone + FROM orders + WHERE NULLIF(cliente_fone, '') IS NOT NULL + AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL GROUP BY normalized_customer_name ), identity_orders AS ( SELECT - order_identity.*, + orders.*, + ${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name, COALESCE( - NULLIF(order_identity.cliente_fone, ''), + NULLIF(orders.cliente_fone, ''), customer_phone_by_name.canonical_phone, - 'name:' || COALESCE(NULLIF(order_identity.cliente_nome, ''), 'Cliente Desconhecido') + 'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido') ) as customer_key - FROM order_identity - LEFT JOIN customer_phone_by_name USING (normalized_customer_name) + FROM orders + LEFT JOIN customer_phone_by_name + ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL} ) `; const CUSTOMER_KEY_SQL = 'customer_key'; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 0482d74..856558d 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -601,10 +601,10 @@ test('getClientAnalytics groups phone-less history through the customer identity assert.equal(clients.length, 1); assert.equal(clients[0].customerKey, '(16) 99999-9999'); assert.equal(clients[0].phone, '(16) 99999-9999'); - assert.match(calls[0].sql, /WITH order_identity AS/); assert.match(calls[0].sql, /customer_phone_by_name AS/); assert.match(calls[0].sql, /ARRAY_AGG\(NULLIF\(cliente_fone, ''\) ORDER BY data_pedido_date DESC NULLS LAST, id DESC\)/); - assert.match(calls[0].sql, /NULLIF\(order_identity\.cliente_fone, ''\),\s+customer_phone_by_name\.canonical_phone/s); + assert.match(calls[0].sql, /WHERE NULLIF\(cliente_fone, ''\) IS NOT NULL/); + assert.match(calls[0].sql, /NULLIF\(orders\.cliente_fone, ''\),\s+customer_phone_by_name\.canonical_phone/s); assert.match(calls[0].sql, /FROM identity_orders/); assert.match(calls[0].sql, /GROUP BY customer_key/); } finally { diff --git a/src/dataService.ts b/src/dataService.ts index db1a6e6..a39e2c4 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -2,6 +2,57 @@ import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSu import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; +const ANALYTICS_CACHE_TTL_MS = 5 * 60 * 1000; +const IN_FLIGHT_CACHE_TTL_MS = 15 * 1000; + +type CacheOptions = { + force?: boolean; +}; + +type ApiCacheEntry = { + expiresAt: number; + data?: T; + promise?: Promise; +}; + +const analyticsCache = new Map>(); + +const getCachedAnalyticsValue = (key: string): T | undefined => { + const entry = analyticsCache.get(key) as ApiCacheEntry | undefined; + if (!entry || entry.data === undefined || entry.expiresAt <= Date.now()) return undefined; + return entry.data; +}; + +const getCachedAnalytics = async (key: string, loader: () => Promise, options: CacheOptions = {}): Promise => { + const now = Date.now(); + const cached = analyticsCache.get(key) as ApiCacheEntry | undefined; + + if (!options.force) { + if (cached?.data !== undefined && cached.expiresAt > now) return cached.data; + if (cached?.promise && cached.expiresAt > now) return cached.promise; + } + + const promise = loader(); + analyticsCache.set(key, { expiresAt: now + IN_FLIGHT_CACHE_TTL_MS, promise }); + + try { + const data = await promise; + if (data === null) { + analyticsCache.delete(key); + } else { + analyticsCache.set(key, { data, expiresAt: Date.now() + ANALYTICS_CACHE_TTL_MS }); + } + return data; + } catch (error) { + analyticsCache.delete(key); + throw error; + } +}; + +const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) +}); export const login = async (email: string, password: string): Promise => { try { @@ -109,49 +160,46 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise => { - try { - const params = new URLSearchParams({ - start: formatDateParam(dateRange.start), - end: formatDateParam(dateRange.end) - }); - const response = await authFetch(`/analytics/dashboard?${params.toString()}`); - if (!response.ok) return null; - return await response.json(); - } catch (error) { - console.error('Fetch dashboard analytics failed', error); - return null; - } +export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise => { + const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch dashboard analytics failed', error); + return null; + } + }, options); }; export const fetchProductAnalytics = async (dateRange: DateRange): Promise => { - try { - const params = new URLSearchParams({ - start: formatDateParam(dateRange.start), - end: formatDateParam(dateRange.end) - }); - const response = await authFetch(`/analytics/products?${params.toString()}`); - if (!response.ok) return []; - return await response.json(); - } catch (error) { - console.error('Fetch product analytics failed', error); - return []; - } + const path = `/analytics/products?${buildDateRangeParams(dateRange).toString()}`; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return []; + return await response.json(); + } catch (error) { + console.error('Fetch product analytics failed', error); + return []; + } + }); }; export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise => { - try { - const params = new URLSearchParams({ - start: formatDateParam(dateRange.start), - end: formatDateParam(dateRange.end) - }); - const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/details?${params.toString()}`); - if (!response.ok) return null; - return await response.json(); - } catch (error) { - console.error('Fetch product details analytics failed', error); - return null; - } + const path = `/analytics/products/${encodeURIComponent(productId)}/details?${buildDateRangeParams(dateRange).toString()}`; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch product details analytics failed', error); + return null; + } + }); }; const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial) => { @@ -162,65 +210,87 @@ const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Par }; export const fetchClientFilterOptions = async (dateRange: DateRange): Promise => { - try { - const params = new URLSearchParams({ - start: formatDateParam(dateRange.start), - end: formatDateParam(dateRange.end) - }); - const response = await authFetch(`/analytics/clients/filters?${params.toString()}`); - if (!response.ok) return { marketplaces: [], salesChannels: [], sellers: [] }; - return await response.json(); - } catch (error) { - console.error('Fetch client filter options failed', error); - return { marketplaces: [], salesChannels: [], sellers: [] }; - } + const path = `/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return { marketplaces: [], salesChannels: [], sellers: [] }; + return await response.json(); + } catch (error) { + console.error('Fetch client filter options failed', error); + return { marketplaces: [], salesChannels: [], sellers: [] }; + } + }); }; -export const fetchRfmAnalytics = async (dateRange: DateRange, filters?: Partial): Promise => { - try { - const params = new URLSearchParams({ - start: formatDateParam(dateRange.start), - end: formatDateParam(dateRange.end) - }); - appendClientMetadataFilterParams(params, filters); - const response = await authFetch(`/analytics/rfm?${params.toString()}`); - if (!response.ok) return null; - return await response.json(); - } catch (error) { - console.error('Fetch RFM analytics failed', error); - return null; - } +const buildRfmAnalyticsPath = (dateRange: DateRange, filters?: Partial) => { + const params = buildDateRangeParams(dateRange); + appendClientMetadataFilterParams(params, filters); + return `/analytics/rfm?${params.toString()}`; }; -export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial): Promise => { - try { - const params = new URLSearchParams({ - start: formatDateParam(dateRange.start), - end: formatDateParam(dateRange.end) - }); - appendClientMetadataFilterParams(params, filters); - const response = await authFetch(`/analytics/clients?${params.toString()}`); - if (!response.ok) return []; - return await response.json(); - } catch (error) { - console.error('Fetch client analytics failed', error); - return []; - } +export const getCachedRfmAnalytics = (dateRange: DateRange, filters?: Partial): RfmAnalytics | undefined => { + return getCachedAnalyticsValue(buildRfmAnalyticsPath(dateRange, filters)); +}; + +export const fetchRfmAnalytics = async (dateRange: DateRange, filters?: Partial, options?: CacheOptions): Promise => { + const path = buildRfmAnalyticsPath(dateRange, filters); + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch RFM analytics failed', error); + return null; + } + }, options); +}; + +const buildClientAnalyticsPath = (dateRange: DateRange, filters?: Partial) => { + const params = buildDateRangeParams(dateRange); + appendClientMetadataFilterParams(params, filters); + return `/analytics/clients?${params.toString()}`; +}; + +export const getCachedClientAnalytics = (dateRange: DateRange, filters?: Partial): ClientAnalyticsItem[] | undefined => { + return getCachedAnalyticsValue(buildClientAnalyticsPath(dateRange, filters)); +}; + +export const getCachedClientFilterOptions = (dateRange: DateRange): ClientFilterOptions | undefined => { + return getCachedAnalyticsValue(`/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`); +}; + +export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial, options?: CacheOptions): Promise => { + const path = buildClientAnalyticsPath(dateRange, filters); + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return []; + return await response.json(); + } catch (error) { + console.error('Fetch client analytics failed', error); + return []; + } + }, options); }; export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise => { - try { - const params = new URLSearchParams({ - start: formatDateParam(dateRange.start), - end: formatDateParam(dateRange.end) - }); - const response = await authFetch(`/analytics/clients/${encodeURIComponent(clientToken)}/details?${params.toString()}`); - if (!response.ok) return null; - return await response.json(); - } catch (error) { - console.error('Fetch client details analytics failed', error); - return null; - } + const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch client details analytics failed', error); + return null; + } + }); +}; + +export const clearAnalyticsCache = () => { + analyticsCache.clear(); }; export const fetchCampaigns = async (): Promise => { diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index e1a7356..f820ba7 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react'; import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types'; -import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics } from '../dataService'; +import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService'; import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; import type { ClientSortOption, ClientSummary } from '../analytics/clients'; @@ -114,11 +114,14 @@ const Clients = () => { const [sortBy, setSortBy] = useState('recent'); const [clientTypeFilter, setClientTypeFilter] = useState('all'); const [metadataFilters, setMetadataFilters] = useState(emptyClientFilters); - const [filterOptions, setFilterOptions] = useState(emptyClientFilterOptions); + const initialFilterOptions = getCachedClientFilterOptions(dateRange); + const initialClientAnalytics = getCachedClientAnalytics(dateRange, emptyClientFilters); + const initialRfmAnalytics = getCachedRfmAnalytics(dateRange, emptyClientFilters); + const [filterOptions, setFilterOptions] = useState(initialFilterOptions || emptyClientFilterOptions); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const filterMenuRef = useRef(null); - const [rfmAnalytics, setRfmAnalytics] = useState(null); - const [clientAnalytics, setClientAnalytics] = useState([]); + const [rfmAnalytics, setRfmAnalytics] = useState(initialRfmAnalytics || null); + const [clientAnalytics, setClientAnalytics] = useState(initialClientAnalytics || []); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -128,15 +131,27 @@ const Clients = () => { let isMounted = true; const loadClients = async () => { - const options = await fetchClientFilterOptions(dateRange); - const clientsData = await fetchClientAnalytics(dateRange, metadataFilters); + const cachedOptions = getCachedClientFilterOptions(dateRange); + const cachedClients = getCachedClientAnalytics(dateRange, metadataFilters); + const cachedRfm = getCachedRfmAnalytics(dateRange, metadataFilters); + + if (isMounted) { + if (cachedOptions) setFilterOptions(cachedOptions); + if (cachedClients) setClientAnalytics(cachedClients); + setRfmAnalytics(cachedRfm || null); + } + + const filterOptionsPromise = fetchClientFilterOptions(dateRange); + const clientsPromise = fetchClientAnalytics(dateRange, metadataFilters); + const rfmPromise = fetchRfmAnalytics(dateRange, metadataFilters); + const [options, clientsData] = await Promise.all([filterOptionsPromise, clientsPromise]); + if (isMounted) { setFilterOptions(options); setClientAnalytics(clientsData); - setRfmAnalytics(null); } - const rfmData = await fetchRfmAnalytics(dateRange, metadataFilters); + const rfmData = await rfmPromise; if (isMounted) { setRfmAnalytics(rfmData); } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 529908e..abd2571 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -55,9 +55,9 @@ const Dashboard = () => { const [serverMetrics, setServerMetrics] = useState(null); const [isMetricsLoading, setIsMetricsLoading] = useState(true); - const loadDashboardMetrics = useCallback(async (range: DateRange) => { + const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => { setIsMetricsLoading(true); - const metrics = await fetchDashboardAnalytics(range); + const metrics = await fetchDashboardAnalytics(range, options); setServerMetrics(metrics); setIsMetricsLoading(false); }, []); @@ -72,7 +72,7 @@ const Dashboard = () => { if (refreshInterval === 0) return; const intervalId = setInterval(() => { - void loadDashboardMetrics(dateRange); + void loadDashboardMetrics(dateRange, { force: true }); }, refreshInterval); return () => clearInterval(intervalId); @@ -84,7 +84,7 @@ const Dashboard = () => { }, [dateRange, ordersData, serverMetrics]); const handleManualRefresh = () => { - void loadDashboardMetrics(dateRange); + void loadDashboardMetrics(dateRange, { force: true }); }; return ( diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx index e46d1e2..ef999b5 100644 --- a/src/pages/Rfm.tsx +++ b/src/pages/Rfm.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; -import { exportToCSV, fetchRfmAnalytics } from '../dataService'; +import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService'; import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types'; const emptyClients: RfmClient[] = []; @@ -127,17 +127,25 @@ const Rfm = () => { setRefreshInterval: (interval: number) => void; }>(); - const [analytics, setAnalytics] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const initialCachedAnalytics = getCachedRfmAnalytics(dateRange); + const [analytics, setAnalytics] = useState(initialCachedAnalytics || null); + const [isLoading, setIsLoading] = useState(!initialCachedAnalytics); const [searchTerm, setSearchTerm] = useState(''); const [segmentFilter, setSegmentFilter] = useState('all'); const [selectedSegmentKey, setSelectedSegmentKey] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); - const loadRfm = useCallback(async (range: DateRange) => { + const loadRfm = useCallback(async (range: DateRange, options?: { force?: boolean }) => { + const cachedAnalytics = options?.force ? undefined : getCachedRfmAnalytics(range); + if (cachedAnalytics) { + setAnalytics(cachedAnalytics); + setIsLoading(false); + return; + } + setIsLoading(true); - const data = await fetchRfmAnalytics(range); + const data = await fetchRfmAnalytics(range, undefined, options); setAnalytics(data); setIsLoading(false); }, []); @@ -152,7 +160,7 @@ const Rfm = () => { if (refreshInterval === 0) return; const intervalId = setInterval(() => { - void loadRfm(dateRange); + void loadRfm(dateRange, { force: true }); }, refreshInterval); return () => clearInterval(intervalId); @@ -276,7 +284,7 @@ const Rfm = () => { onChange={setDateRange} refreshInterval={refreshInterval} setRefreshInterval={setRefreshInterval} - onManualRefresh={() => void loadRfm(dateRange)} + onManualRefresh={() => void loadRfm(dateRange, { force: true })} />