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`);
|
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user