diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index cdb7a5d..04dfeb4 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -1116,7 +1116,7 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`); } - const [summaryResult, periodResult, patternResult] = await Promise.all([ + const [summaryResult, periodResult, weekdayPatternResult, hourPatternResult] = await Promise.all([ pool.query(` ${CUSTOMER_IDENTITY_CTE} SELECT @@ -1163,7 +1163,29 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { WHERE ${CUSTOMER_KEY_SQL} = $1 AND data_pedido_date IS NOT NULL ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; - `, [resolvedCustomerKey]) + `, [resolvedCustomerKey]), + pool.query(` + ${CUSTOMER_IDENTITY_CTE}, + order_events AS ( + SELECT DISTINCT ON ( + COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) + ) + created_at + FROM identity_orders + WHERE ${CUSTOMER_KEY_SQL} = $1 + AND data_pedido_date IS NOT NULL + AND created_at >= NOW() - ($2::int * INTERVAL '1 day') + ORDER BY + COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text), + created_at ASC NULLS LAST + ) + SELECT + EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour, + COUNT(*)::int as order_count + FROM order_events + GROUP BY hour + ORDER BY hour ASC; + `, [resolvedCustomerKey, CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS]) ]); const summary = summaryResult.rows[0] || {}; @@ -1185,7 +1207,7 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { let periodSpent = 0; let periodItems = 0; - patternResult.rows.forEach(row => { + weekdayPatternResult.rows.forEach(row => { const groupKey = getOrderGroupKey(row); if (patternOrderKeys.has(groupKey)) return; @@ -1195,10 +1217,14 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { if (weekdayIndex !== null) { weekdayCounts[weekdayIndex].value += 1; } + }); - const orderHour = getHourFromTimestamp(row.created_at) ?? getHourFromTimestamp(row.data_pedido); - if (orderHour !== null) { - hourCounts[orderHour].value += 1; + hourPatternResult.rows.forEach(row => { + const orderHour = row.hour === null || row.hour === undefined ? null : Number(row.hour); + const orderCount = toNumber(row.order_count); + + if (orderHour !== null && Number.isInteger(orderHour) && hourCounts[orderHour]) { + hourCounts[orderHour].value += orderCount; } }); @@ -1283,6 +1309,8 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { chartData, purchaseWeekdays: weekdayCounts, purchaseHours: hourCounts, + purchaseWeekdayRangeLabel: 'Todo período', + purchaseHourRangeLabel: `Últimos ${CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS} dias`, groupedOrders }; }; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 9343994..21d583d 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -908,6 +908,14 @@ test('getClientDetailsAnalytics resolves legacy name tokens to the canonical pho }; } + if (sql.includes('created_at >= NOW()')) { + return { + rows: [ + { hour: 9, order_count: 1 } + ] + }; + } + return { rows: [ { @@ -941,7 +949,7 @@ test('getClientDetailsAnalytics resolves legacy name tokens to the canonical pho try { const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' }); - assert.equal(calls.length, 5); + assert.equal(calls.length, 6); assert.match(calls[0].sql, /FROM client_identity_tokens/); assert.deepEqual(calls[0].params, [clientToken]); assert.match(calls[1].sql, /SELECT customer_key/); @@ -952,10 +960,12 @@ test('getClientDetailsAnalytics resolves legacy name tokens to the canonical pho assert.match(calls[3].sql, /data_pedido_date >= \$2::date/); assert.match(calls[3].sql, /data_pedido_date <= \$3::date/); assert.deepEqual(calls[3].params, ['(16) 99999-9999', '2026-06-01', '2026-06-15']); - assert.match(calls[4].sql, /created_at/); + assert.match(calls[4].sql, /data_pedido_date IS NOT NULL/); assert.doesNotMatch(calls[4].sql, /data_pedido_date >=/); assert.doesNotMatch(calls[4].sql, /data_pedido_date <=/); assert.deepEqual(calls[4].params, ['(16) 99999-9999']); + assert.match(calls[5].sql, /created_at >= NOW\(\) - \(\$2::int \* INTERVAL '1 day'\)/); + assert.deepEqual(calls[5].params, ['(16) 99999-9999', 60]); assert.equal(details.clientName, 'Cliente Sem Fone'); assert.equal(details.clientPhone, '(16) 99999-9999'); assert.equal(details.allTimeOrderCount, 3); @@ -964,6 +974,9 @@ test('getClientDetailsAnalytics resolves legacy name tokens to the canonical pho assert.equal(details.periodOrderCount, 1); assert.equal(details.periodAverageTicket, 25); assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 25 }]); + assert.equal(details.purchaseWeekdayRangeLabel, 'Todo período'); + assert.equal(details.purchaseHourRangeLabel, 'Últimos 60 dias'); + assert.deepEqual(details.purchaseHours[9], { label: '09h', value: 1 }); assert.equal(details.groupedOrders.length, 1); assert.equal(details.groupedOrders[0].orderTotal, 25); assert.equal(details.groupedOrders[0].items.length, 2); @@ -995,6 +1008,15 @@ test('getClientDetailsAnalytics groups single-day spend chart by hour', async () }; } + if (sql.includes('created_at >= NOW()')) { + return { + rows: [ + { hour: 9, order_count: 1 }, + { hour: 18, order_count: 1 } + ] + }; + } + assert.deepEqual(params[0], '(16) 99999-9999'); return { rows: [ @@ -1034,6 +1056,10 @@ test('getClientDetailsAnalytics groups single-day spend chart by hour', async () 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.deepEqual(details.purchaseHours[9], { label: '09h', value: 1 }); + assert.deepEqual(details.purchaseHours[18], { label: '18h', value: 1 }); + assert.equal(details.purchaseWeekdayRangeLabel, 'Todo período'); + assert.equal(details.purchaseHourRangeLabel, 'Últimos 60 dias'); assert.equal(details.periodSpent, 50); } finally { pool.query = originalQuery; diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index ce94313..99d85b3 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -230,6 +230,8 @@ const ClientDetails = () => { chartData, groupedOrders, purchaseHours = [], + purchaseHourRangeLabel = 'Últimos 60 dias', + purchaseWeekdayRangeLabel = 'Todo período', purchaseWeekdays = [], allTimeOrderCount, clientName, @@ -382,18 +384,20 @@ const ClientDetails = () => {

Padrão de Compra

-

Quando este cliente costuma comprar.

+

Quando este cliente costuma comprar, separando histórico de data e horário confiável.

- - Todo período -
-

Compras por Dia

+
+

Compras por Dia

+ + {purchaseWeekdayRangeLabel} + +
{hasWeekdayPattern ? ( -
+
@@ -416,9 +420,14 @@ const ClientDetails = () => {
-

Compras por Horário

+
+

Compras por Horário

+ + {purchaseHourRangeLabel} + +
{hasHourPattern ? ( -
+
@@ -443,7 +452,7 @@ const ClientDetails = () => {
) : (
- Sem horário de compra disponível para este período. + Sem horário de compra disponível para os últimos 60 dias.
)}
diff --git a/src/types.ts b/src/types.ts index ff0baa5..653cbc0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -245,6 +245,8 @@ export interface ClientDetailsAnalytics { label: string; value: number; }>; + purchaseWeekdayRangeLabel?: string; + purchaseHourRangeLabel?: string; groupedOrders: GroupedClientOrder[]; allTimeOrderCount: number; clientName: string;