diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 35ddd03..cdb7a5d 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -2,6 +2,7 @@ const crypto = require('node:crypto'); const { pool } = require('../db'); const RFM_QUERY_TIMEOUT_MS = 15000; +const CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS = 60; const RECENT_MAX_DAYS = 7; const COOLING_MAX_DAYS = 15; const LOST_MIN_DAYS = 30; @@ -1005,7 +1006,8 @@ const getClientFilterOptions = async () => { }; const getClientPurchasePatternAnalytics = async () => { - const result = await pool.query(` + const [weekdayResult, hourResult] = await Promise.all([ + pool.query(` ${CUSTOMER_IDENTITY_CTE}, order_events AS ( SELECT DISTINCT ON ( @@ -1023,12 +1025,35 @@ const getClientPurchasePatternAnalytics = async () => { ) SELECT EXTRACT(DOW FROM data_pedido_date)::int as weekday, + COUNT(*)::int as order_count + FROM order_events + GROUP BY weekday + ORDER BY weekday ASC; + `), + pool.query(` + ${CUSTOMER_IDENTITY_CTE}, + order_events AS ( + SELECT DISTINCT ON ( + ${CUSTOMER_KEY_SQL}, + COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) + ) + created_at + FROM identity_orders + WHERE data_pedido_date IS NOT NULL + AND created_at >= NOW() - ($1::int * INTERVAL '1 day') + ORDER BY + ${CUSTOMER_KEY_SQL}, + 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 weekday, hour - ORDER BY weekday ASC, hour ASC; - `); + GROUP BY hour + ORDER BY hour ASC; + `, [CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS]) + ]); const purchaseWeekdays = WEEKDAY_LABELS.map(label => ({ label, value: 0 })); const purchaseHours = Array.from({ length: 24 }, (_, hour) => ({ @@ -1036,14 +1061,18 @@ const getClientPurchasePatternAnalytics = async () => { value: 0 })); - result.rows.forEach(row => { + weekdayResult.rows.forEach(row => { const weekday = row.weekday === null || row.weekday === undefined ? null : Number(row.weekday); - const hour = row.hour === null || row.hour === undefined ? null : Number(row.hour); const orderCount = toNumber(row.order_count); if (weekday !== null && Number.isInteger(weekday) && purchaseWeekdays[weekday]) { purchaseWeekdays[weekday].value += orderCount; } + }); + + hourResult.rows.forEach(row => { + const hour = row.hour === null || row.hour === undefined ? null : Number(row.hour); + const orderCount = toNumber(row.order_count); if (hour !== null && Number.isInteger(hour) && purchaseHours[hour]) { purchaseHours[hour].value += orderCount; @@ -1052,7 +1081,9 @@ const getClientPurchasePatternAnalytics = async () => { return { purchaseWeekdays, - purchaseHours + purchaseHours, + weekdayRangeLabel: 'Todo período', + hourRangeLabel: `Últimos ${CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS} dias` }; }; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 76ad62d..9343994 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -831,21 +831,35 @@ test('getClientFilterOptions returns all distinct order metadata options', async } }); -test('getClientPurchasePatternAnalytics returns all-time weekday and hour counts', async () => { +test('getClientPurchasePatternAnalytics returns all-time weekday and recent hour counts', async () => { const originalQuery = pool.query; + const calls = []; pool.query = async (sql, params = []) => { - assert.deepEqual(params, []); + calls.push({ sql, params }); assert.match(sql, /order_events AS/); assert.match(sql, /DISTINCT ON \(\s+customer_key,\s+COALESCE\(NULLIF\(pedido_id, ''\), data_pedido \|\| '_' \|\| valor_pedido::text\)\s+\)/); - assert.match(sql, /EXTRACT\(DOW FROM data_pedido_date\)::int as weekday/); + + if (sql.includes('EXTRACT(DOW FROM data_pedido_date)')) { + assert.deepEqual(params, []); + assert.doesNotMatch(sql, /created_at >= NOW\(\)/); + return { + rows: [ + { weekday: 1, order_count: 2 }, + { weekday: 5, order_count: 2 } + ] + }; + } + + assert.deepEqual(params, [60]); + assert.match(sql, /created_at >= NOW\(\) - \(\$1::int \* INTERVAL '1 day'\)/); assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/); return { rows: [ - { weekday: 1, hour: 9, order_count: 2 }, - { weekday: 5, hour: 18, order_count: 1 }, - { weekday: 5, hour: null, order_count: 1 } + { hour: 9, order_count: 2 }, + { hour: 18, order_count: 1 }, + { hour: null, order_count: 1 } ] }; }; @@ -853,6 +867,7 @@ test('getClientPurchasePatternAnalytics returns all-time weekday and hour counts try { const pattern = await getClientPurchasePatternAnalytics(); + assert.equal(calls.length, 2); assert.equal(pattern.purchaseWeekdays.length, 7); assert.equal(pattern.purchaseHours.length, 24); assert.deepEqual(pattern.purchaseWeekdays[1], { label: 'Seg', value: 2 }); @@ -860,6 +875,8 @@ test('getClientPurchasePatternAnalytics returns all-time weekday and hour counts assert.deepEqual(pattern.purchaseHours[9], { label: '09h', value: 2 }); assert.deepEqual(pattern.purchaseHours[18], { label: '18h', value: 1 }); assert.deepEqual(pattern.purchaseHours[0], { label: '00h', value: 0 }); + assert.equal(pattern.weekdayRangeLabel, 'Todo período'); + assert.equal(pattern.hourRangeLabel, 'Últimos 60 dias'); } finally { pool.query = originalQuery; } diff --git a/src/dataService.ts b/src/dataService.ts index 189efbf..6076e37 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -301,6 +301,8 @@ export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Parti }; const emptyClientPurchasePattern = (): ClientPurchasePatternAnalytics => ({ + weekdayRangeLabel: 'Todo período', + hourRangeLabel: 'Últimos 60 dias', purchaseWeekdays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'].map(label => ({ label, value: 0 })), purchaseHours: Array.from({ length: 24 }, (_, hour) => ({ label: `${String(hour).padStart(2, '0')}h`, diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index d74ce35..42687c4 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -522,6 +522,8 @@ const Clients = () => { const shouldShowRfmLoading = isRfmLoading && clientAnalytics.length > 0; const hasWeekdayPattern = purchasePattern?.purchaseWeekdays.some(day => day.value > 0) ?? false; const hasHourPattern = purchasePattern?.purchaseHours.some(hour => hour.value > 0) ?? false; + const weekdayRangeLabel = purchasePattern?.weekdayRangeLabel || 'Todo período'; + const hourRangeLabel = purchasePattern?.hourRangeLabel || 'Últimos 60 dias'; return (
@@ -831,18 +833,20 @@ const Clients = () => {

Padrão de Compra

-

Quando os clientes costumam comprar.

+

Quando os clientes costumam comprar, separando histórico de data e horário confiável.

- - Todo período -
-

Compras por Dia

+
+

Compras por Dia

+ + {weekdayRangeLabel} + +
{hasWeekdayPattern ? ( -
+
@@ -865,9 +869,14 @@ const Clients = () => {
-

Compras por Horário

+
+

Compras por Horário

+ + {hourRangeLabel} + +
{hasHourPattern ? ( -
+
diff --git a/src/types.ts b/src/types.ts index 12e7745..ff0baa5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -168,6 +168,8 @@ export interface ClientFilterOptions { } export interface ClientPurchasePatternAnalytics { + weekdayRangeLabel?: string; + hourRangeLabel?: string; purchaseWeekdays: Array<{ label: string; value: number;