Smooth analytics page reloads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34s

This commit is contained in:
Cauê Faleiros
2026-06-23 14:45:21 -03:00
parent 335d231903
commit 7d60073757
7 changed files with 233 additions and 126 deletions

View File

@@ -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 (

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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<T> = {
expiresAt: number;
data?: T;
promise?: Promise<T>;
};
const analyticsCache = new Map<string, ApiCacheEntry<unknown>>();
const getCachedAnalyticsValue = <T>(key: string): T | undefined => {
const entry = analyticsCache.get(key) as ApiCacheEntry<T> | undefined;
if (!entry || entry.data === undefined || entry.expiresAt <= Date.now()) return undefined;
return entry.data;
};
const getCachedAnalytics = async <T>(key: string, loader: () => Promise<T>, options: CacheOptions = {}): Promise<T> => {
const now = Date.now();
const cached = analyticsCache.get(key) as ApiCacheEntry<T> | 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<boolean> => {
try {
@@ -109,49 +160,46 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
return response;
};
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/dashboard?${params.toString()}`);
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<ProductAnalyticsItem[]> => {
const path = `/analytics/products?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/products?${params.toString()}`);
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<ProductDetailsAnalytics | null> => {
const path = `/analytics/products/${encodeURIComponent(productId)}/details?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/details?${params.toString()}`);
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<ClientMetadataFilters>) => {
@@ -162,65 +210,87 @@ const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Par
};
export const fetchClientFilterOptions = async (dateRange: DateRange): Promise<ClientFilterOptions> => {
const path = `/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/clients/filters?${params.toString()}`);
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<ClientMetadataFilters>): Promise<RfmAnalytics | null> => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const buildRfmAnalyticsPath = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>) => {
const params = buildDateRangeParams(dateRange);
appendClientMetadataFilterParams(params, filters);
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
return `/analytics/rfm?${params.toString()}`;
};
export const getCachedRfmAnalytics = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): RfmAnalytics | undefined => {
return getCachedAnalyticsValue<RfmAnalytics>(buildRfmAnalyticsPath(dateRange, filters));
};
export const fetchRfmAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>, options?: CacheOptions): Promise<RfmAnalytics | null> => {
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);
};
export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): Promise<ClientAnalyticsItem[]> => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const buildClientAnalyticsPath = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>) => {
const params = buildDateRangeParams(dateRange);
appendClientMetadataFilterParams(params, filters);
const response = await authFetch(`/analytics/clients?${params.toString()}`);
return `/analytics/clients?${params.toString()}`;
};
export const getCachedClientAnalytics = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): ClientAnalyticsItem[] | undefined => {
return getCachedAnalyticsValue<ClientAnalyticsItem[]>(buildClientAnalyticsPath(dateRange, filters));
};
export const getCachedClientFilterOptions = (dateRange: DateRange): ClientFilterOptions | undefined => {
return getCachedAnalyticsValue<ClientFilterOptions>(`/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`);
};
export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>, options?: CacheOptions): Promise<ClientAnalyticsItem[]> => {
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<ClientDetailsAnalytics | null> => {
const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/clients/${encodeURIComponent(clientToken)}/details?${params.toString()}`);
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<CampaignQueueSummary | null> => {

View File

@@ -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<ClientSortOption>('recent');
const [clientTypeFilter, setClientTypeFilter] = useState('all');
const [metadataFilters, setMetadataFilters] = useState<ClientMetadataFilters>(emptyClientFilters);
const [filterOptions, setFilterOptions] = useState<ClientFilterOptions>(emptyClientFilterOptions);
const initialFilterOptions = getCachedClientFilterOptions(dateRange);
const initialClientAnalytics = getCachedClientAnalytics(dateRange, emptyClientFilters);
const initialRfmAnalytics = getCachedRfmAnalytics(dateRange, emptyClientFilters);
const [filterOptions, setFilterOptions] = useState<ClientFilterOptions>(initialFilterOptions || emptyClientFilterOptions);
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(null);
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>([]);
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(initialRfmAnalytics || null);
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(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);
}

View File

@@ -55,9 +55,9 @@ const Dashboard = () => {
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(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 (

View File

@@ -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<RfmAnalytics | null>(null);
const [isLoading, setIsLoading] = useState(true);
const initialCachedAnalytics = getCachedRfmAnalytics(dateRange);
const [analytics, setAnalytics] = useState<RfmAnalytics | null>(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 })}
/>
</div>
</div>