Apply recent hour pattern to client details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m36s

This commit is contained in:
Cauê Faleiros
2026-07-01 15:05:47 -03:00
parent 27d2980b32
commit c738b804af
4 changed files with 82 additions and 17 deletions

View File

@@ -1116,7 +1116,7 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`); 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(` pool.query(`
${CUSTOMER_IDENTITY_CTE} ${CUSTOMER_IDENTITY_CTE}
SELECT SELECT
@@ -1163,7 +1163,29 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
WHERE ${CUSTOMER_KEY_SQL} = $1 WHERE ${CUSTOMER_KEY_SQL} = $1
AND data_pedido_date IS NOT NULL AND data_pedido_date IS NOT NULL
ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; 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] || {}; const summary = summaryResult.rows[0] || {};
@@ -1185,7 +1207,7 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
let periodSpent = 0; let periodSpent = 0;
let periodItems = 0; let periodItems = 0;
patternResult.rows.forEach(row => { weekdayPatternResult.rows.forEach(row => {
const groupKey = getOrderGroupKey(row); const groupKey = getOrderGroupKey(row);
if (patternOrderKeys.has(groupKey)) return; if (patternOrderKeys.has(groupKey)) return;
@@ -1195,10 +1217,14 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
if (weekdayIndex !== null) { if (weekdayIndex !== null) {
weekdayCounts[weekdayIndex].value += 1; weekdayCounts[weekdayIndex].value += 1;
} }
});
const orderHour = getHourFromTimestamp(row.created_at) ?? getHourFromTimestamp(row.data_pedido); hourPatternResult.rows.forEach(row => {
if (orderHour !== null) { const orderHour = row.hour === null || row.hour === undefined ? null : Number(row.hour);
hourCounts[orderHour].value += 1; 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, chartData,
purchaseWeekdays: weekdayCounts, purchaseWeekdays: weekdayCounts,
purchaseHours: hourCounts, purchaseHours: hourCounts,
purchaseWeekdayRangeLabel: 'Todo período',
purchaseHourRangeLabel: `Últimos ${CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS} dias`,
groupedOrders groupedOrders
}; };
}; };

View File

@@ -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 { return {
rows: [ rows: [
{ {
@@ -941,7 +949,7 @@ test('getClientDetailsAnalytics resolves legacy name tokens to the canonical pho
try { try {
const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' }); 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.match(calls[0].sql, /FROM client_identity_tokens/);
assert.deepEqual(calls[0].params, [clientToken]); assert.deepEqual(calls[0].params, [clientToken]);
assert.match(calls[1].sql, /SELECT customer_key/); 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 >= \$2::date/);
assert.match(calls[3].sql, /data_pedido_date <= \$3::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.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.doesNotMatch(calls[4].sql, /data_pedido_date <=/); assert.doesNotMatch(calls[4].sql, /data_pedido_date <=/);
assert.deepEqual(calls[4].params, ['(16) 99999-9999']); 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.clientName, 'Cliente Sem Fone');
assert.equal(details.clientPhone, '(16) 99999-9999'); assert.equal(details.clientPhone, '(16) 99999-9999');
assert.equal(details.allTimeOrderCount, 3); 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.periodOrderCount, 1);
assert.equal(details.periodAverageTicket, 25); assert.equal(details.periodAverageTicket, 25);
assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 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.length, 1);
assert.equal(details.groupedOrders[0].orderTotal, 25); assert.equal(details.groupedOrders[0].orderTotal, 25);
assert.equal(details.groupedOrders[0].items.length, 2); 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'); assert.deepEqual(params[0], '(16) 99999-9999');
return { return {
rows: [ rows: [
@@ -1034,6 +1056,10 @@ test('getClientDetailsAnalytics groups single-day spend chart by hour', async ()
assert.equal(details.chartData.length, 24); assert.equal(details.chartData.length, 24);
assert.deepEqual(details.chartData[9], { date: '09h', value: 20 }); assert.deepEqual(details.chartData[9], { date: '09h', value: 20 });
assert.deepEqual(details.chartData[18], { date: '18h', value: 30 }); 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); assert.equal(details.periodSpent, 50);
} finally { } finally {
pool.query = originalQuery; pool.query = originalQuery;

View File

@@ -230,6 +230,8 @@ const ClientDetails = () => {
chartData, chartData,
groupedOrders, groupedOrders,
purchaseHours = [], purchaseHours = [],
purchaseHourRangeLabel = 'Últimos 60 dias',
purchaseWeekdayRangeLabel = 'Todo período',
purchaseWeekdays = [], purchaseWeekdays = [],
allTimeOrderCount, allTimeOrderCount,
clientName, clientName,
@@ -382,18 +384,20 @@ const ClientDetails = () => {
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h3> <h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando este cliente costuma comprar.</p> <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando este cliente costuma comprar, separando histórico de data e horário confiável.</p>
</div> </div>
<span className="w-fit rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-card dark:text-dark-muted">
Todo período
</span>
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h4> <h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h4>
<span className="shrink-0 rounded-full border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-surface dark:text-dark-muted">
{purchaseWeekdayRangeLabel}
</span>
</div>
{hasWeekdayPattern ? ( {hasWeekdayPattern ? (
<div className="mt-5 h-56"> <div className="h-56">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={purchaseWeekdays} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}> <BarChart data={purchaseWeekdays} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
@@ -416,9 +420,14 @@ const ClientDetails = () => {
</div> </div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h4> <h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h4>
<span className="shrink-0 rounded-full border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-surface dark:text-dark-muted">
{purchaseHourRangeLabel}
</span>
</div>
{hasHourPattern ? ( {hasHourPattern ? (
<div className="mt-5 h-56"> <div className="h-56">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={purchaseHours} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}> <BarChart data={purchaseHours} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
@@ -443,7 +452,7 @@ const ClientDetails = () => {
</div> </div>
) : ( ) : (
<div className="flex h-56 items-center justify-center px-6 text-center text-sm font-semibold text-zinc-500 dark:text-dark-muted"> <div className="flex h-56 items-center justify-center px-6 text-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Sem horário de compra disponível para este período. Sem horário de compra disponível para os últimos 60 dias.
</div> </div>
)} )}
</div> </div>

View File

@@ -245,6 +245,8 @@ export interface ClientDetailsAnalytics {
label: string; label: string;
value: number; value: number;
}>; }>;
purchaseWeekdayRangeLabel?: string;
purchaseHourRangeLabel?: string;
groupedOrders: GroupedClientOrder[]; groupedOrders: GroupedClientOrder[];
allTimeOrderCount: number; allTimeOrderCount: number;
clientName: string; clientName: string;