Apply recent hour pattern to client details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m36s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m36s
This commit is contained in:
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<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 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="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>
|
||||
<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 ? (
|
||||
<div className="mt-5 h-56">
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={purchaseWeekdays} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
||||
@@ -416,9 +420,14 @@ const ClientDetails = () => {
|
||||
</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="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>
|
||||
<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 ? (
|
||||
<div className="mt-5 h-56">
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={purchaseHours} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
||||
@@ -443,7 +452,7 @@ const ClientDetails = () => {
|
||||
</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">
|
||||
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>
|
||||
|
||||
@@ -245,6 +245,8 @@ export interface ClientDetailsAnalytics {
|
||||
label: string;
|
||||
value: number;
|
||||
}>;
|
||||
purchaseWeekdayRangeLabel?: string;
|
||||
purchaseHourRangeLabel?: string;
|
||||
groupedOrders: GroupedClientOrder[];
|
||||
allTimeOrderCount: number;
|
||||
clientName: string;
|
||||
|
||||
Reference in New Issue
Block a user