diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 87e1a48..446e1ef 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -414,6 +414,8 @@ const getDateOnly = (value) => { return match ? match[1] : null; }; +const isSingleDayRange = (start, end) => Boolean(start && end && start === end); + const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab']; const getWeekdayIndex = (value) => { @@ -751,6 +753,7 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); + const useHourlyChart = isSingleDayRange(normalizedStart, normalizedEnd); const periodParams = [normalizedProductId]; const periodFilters = [ 'produto_id = $1', @@ -798,7 +801,16 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { LEFT JOIN order_info ON order_info.id = selected_product.id WHERE stock_info.id IS NOT NULL OR order_info.id IS NOT NULL; `, [normalizedProductId]), - pool.query(` + pool.query(useHourlyChart ? ` + SELECT + EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour, + COALESCE(SUM(quantidade), 0) as quantity_sold, + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue + FROM orders + WHERE ${periodFilters.join(' AND ')} + GROUP BY hour + ORDER BY hour ASC; + ` : ` SELECT data_pedido_date, MAX(data_pedido) as date_label, @@ -814,10 +826,18 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { const summary = summaryResult.rows[0]; if (!summary) return null; - const chartData = periodResult.rows.map(row => ({ - date: row.date_label || getDateOnly(row.data_pedido_date) || '', - value: toNumber(row.quantity_sold) - })); + const chartData = useHourlyChart + ? Array.from({ length: 24 }, (_, hour) => { + const row = periodResult.rows.find(item => toNumber(item.hour) === hour); + return { + date: `${String(hour).padStart(2, '0')}h`, + value: row ? toNumber(row.quantity_sold) : 0 + }; + }) + : periodResult.rows.map(row => ({ + date: row.date_label || getDateOnly(row.data_pedido_date) || '', + value: toNumber(row.quantity_sold) + })); const totalSold = periodResult.rows.reduce((sum, row) => sum + toNumber(row.quantity_sold), 0); const totalRevenue = periodResult.rows.reduce((sum, row) => sum + toNumber(row.revenue), 0); @@ -934,6 +954,7 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); + const useHourlyChart = isSingleDayRange(normalizedStart, normalizedEnd); const periodParams = [resolvedCustomerKey]; const periodFilters = [ `${CUSTOMER_KEY_SQL} = $1`, @@ -1006,6 +1027,10 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { const groupedOrdersByKey = new Map(); const spentByDate = new Map(); + const spentByHour = Array.from({ length: 24 }, (_, hour) => ({ + date: `${String(hour).padStart(2, '0')}h`, + value: 0 + })); const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 })); const hourCounts = Array.from({ length: 24 }, (_, hour) => ({ label: `${String(hour).padStart(2, '0')}h`, @@ -1047,6 +1072,13 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { spentByDate.set(dateLabel, currentDateSpend); } + if (useHourlyChart) { + const orderHour = getHourFromTimestamp(row.created_at) ?? getHourFromTimestamp(row.data_pedido); + if (orderHour !== null) { + spentByHour[orderHour].value += itemRevenue; + } + } + if (!groupedOrdersByKey.has(groupKey)) { groupedOrdersByKey.set(groupKey, { date: dateLabel, @@ -1082,9 +1114,11 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { const groupedOrders = [...groupedOrdersByKey.values()] .sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate))) .map(({ sortDate, ...group }) => group); - const chartData = [...spentByDate.values()] - .sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate))) - .map(({ sortDate, ...entry }) => entry); + const chartData = useHourlyChart + ? spentByHour + : [...spentByDate.values()] + .sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate))) + .map(({ sortDate, ...entry }) => entry); const periodOrderCount = groupedOrders.length; return { diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 5857949..5ed8aa1 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -604,6 +604,42 @@ test('getProductDetailsAnalytics keeps known products visible with zero period s } }); +test('getProductDetailsAnalytics groups single-day chart by hour', async () => { + const originalQuery = pool.query; + + pool.query = async (sql) => { + if (sql.includes('selected_product AS')) { + return { + rows: [{ + id: '919483307', + name: 'Produto com venda por hora', + price: 11.9 + }] + }; + } + + assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/); + return { + rows: [ + { hour: 9, quantity_sold: 2, revenue: 23.8 }, + { hour: 18, quantity_sold: 3, revenue: 35.7 } + ] + }; + }; + + try { + const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-22', end: '2026-06-22' }); + + assert.equal(details.chartData.length, 24); + assert.deepEqual(details.chartData[9], { date: '09h', value: 2 }); + assert.deepEqual(details.chartData[18], { date: '18h', value: 3 }); + assert.equal(details.totalSold, 5); + assert.equal(details.totalRevenue, 59.5); + } finally { + pool.query = originalQuery; + } +}); + test('getClientAnalytics returns opaque client tokens', async () => { const originalQuery = pool.query; const calls = []; @@ -845,6 +881,74 @@ test('getClientDetailsAnalytics resolves legacy name tokens to the canonical pho } }); +test('getClientDetailsAnalytics groups single-day spend chart by hour', async () => { + const originalQuery = pool.query; + const clientToken = createClientToken('(16) 99999-9999'); + + pool.query = async (sql, params = []) => { + if (sql.includes('FROM client_identity_tokens')) { + return { rows: [{ customer_key: '(16) 99999-9999' }] }; + } + + if (sql.includes('SELECT customer_key') && sql.includes('FROM identity_orders')) { + return { rows: [{ customer_key: '(16) 99999-9999' }] }; + } + + if (sql.includes('all_time_order_count')) { + return { + rows: [{ + name: 'Cliente Teste', + phone: '(16) 99999-9999', + all_time_order_count: 2 + }] + }; + } + + assert.deepEqual(params[0], '(16) 99999-9999'); + return { + rows: [ + { + cliente_nome: 'Cliente Teste', + cliente_fone: '(16) 99999-9999', + data_pedido: '22-06-2026', + data_pedido_date: '2026-06-22', + valor_pedido: 20, + produto_id: 'produto-1', + produto_descricao: 'Produto A', + quantidade: 2, + valor_unitario: 10, + pedido_id: 'pedido-1', + created_at: '2026-06-22T09:15:00.000-03:00' + }, + { + cliente_nome: 'Cliente Teste', + cliente_fone: '(16) 99999-9999', + data_pedido: '22-06-2026', + data_pedido_date: '2026-06-22', + valor_pedido: 30, + produto_id: 'produto-2', + produto_descricao: 'Produto B', + quantidade: 1, + valor_unitario: 30, + pedido_id: 'pedido-2', + created_at: '2026-06-22T18:30:00.000-03:00' + } + ] + }; + }; + + try { + const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-22', end: '2026-06-22' }); + + assert.equal(details.chartData.length, 24); + assert.deepEqual(details.chartData[9], { date: '09h', value: 20 }); + assert.deepEqual(details.chartData[18], { date: '18h', value: 30 }); + assert.equal(details.periodSpent, 50); + } finally { + pool.query = originalQuery; + } +}); + test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => { const originalQuery = pool.query; pool.query = async () => { diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index 27c8c7d..9e72f2a 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -15,6 +15,13 @@ const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)'; const WEEKDAY_BAR_COLOR = '#25C2FF'; const HOUR_BAR_COLOR = '#52DFA0'; +const formatDateKey = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + type CustomTooltipProps = { active?: boolean; payload?: Array<{ value: number }>; @@ -239,6 +246,7 @@ const ClientDetails = () => { const hasWeekdayPattern = purchaseWeekdays.some(day => day.value > 0); const hasHourPattern = purchaseHours.some(hour => hour.value > 0); const isRefreshing = isLoading && Boolean(details); + const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); return (
@@ -323,7 +331,9 @@ const ClientDetails = () => {
-

Gasto por Data

+

+ Gasto por {isSingleDayRange ? 'Horário' : 'Data'} +

{chartData.length === 0 ? (
Nenhum gasto no período selecionado. @@ -331,7 +341,7 @@ const ClientDetails = () => { ) : (
- + @@ -345,10 +355,10 @@ const ClientDetails = () => { fontSize={10} tickLine={false} axisLine={false} - interval={0} - angle={-45} - textAnchor="end" - height={80} + interval={isSingleDayRange ? 2 : 0} + angle={isSingleDayRange ? 0 : -45} + textAnchor={isSingleDayRange ? 'middle' : 'end'} + height={isSingleDayRange ? 24 : 80} /> formatCurrency(Number(value))} /> } cursor={{ fill: CHART_CURSOR_COLOR }} /> diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 8d8ede0..5a22b86 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -12,6 +12,13 @@ const CHART_AXIS_COLOR = 'var(--chart-axis)'; const CHART_CURSOR_COLOR = 'var(--chart-cursor)'; const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)'; +const formatDateKey = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + type CustomTooltipProps = { active?: boolean; payload?: Array<{ value: number }>; @@ -126,6 +133,7 @@ const ProductDetails = () => { const { productInfo, chartData, totalSold, totalRevenue } = details; const isRefreshing = isLoading && Boolean(details); + const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); return (
@@ -177,10 +185,12 @@ const ProductDetails = () => {
-

Volume de Vendas por Data

+

+ Volume de Vendas por {isSingleDayRange ? 'Horário' : 'Data'} +

- + @@ -190,10 +200,10 @@ const ProductDetails = () => { } cursor={{ fill: CHART_CURSOR_COLOR }} />