Use recent data for purchase hour pattern
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
This commit is contained in:
@@ -2,6 +2,7 @@ const crypto = require('node:crypto');
|
|||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
|
|
||||||
const RFM_QUERY_TIMEOUT_MS = 15000;
|
const RFM_QUERY_TIMEOUT_MS = 15000;
|
||||||
|
const CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS = 60;
|
||||||
const RECENT_MAX_DAYS = 7;
|
const RECENT_MAX_DAYS = 7;
|
||||||
const COOLING_MAX_DAYS = 15;
|
const COOLING_MAX_DAYS = 15;
|
||||||
const LOST_MIN_DAYS = 30;
|
const LOST_MIN_DAYS = 30;
|
||||||
@@ -1005,7 +1006,8 @@ const getClientFilterOptions = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getClientPurchasePatternAnalytics = async () => {
|
const getClientPurchasePatternAnalytics = async () => {
|
||||||
const result = await pool.query(`
|
const [weekdayResult, hourResult] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
${CUSTOMER_IDENTITY_CTE},
|
${CUSTOMER_IDENTITY_CTE},
|
||||||
order_events AS (
|
order_events AS (
|
||||||
SELECT DISTINCT ON (
|
SELECT DISTINCT ON (
|
||||||
@@ -1023,12 +1025,35 @@ const getClientPurchasePatternAnalytics = async () => {
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
EXTRACT(DOW FROM data_pedido_date)::int as weekday,
|
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,
|
EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour,
|
||||||
COUNT(*)::int as order_count
|
COUNT(*)::int as order_count
|
||||||
FROM order_events
|
FROM order_events
|
||||||
GROUP BY weekday, hour
|
GROUP BY hour
|
||||||
ORDER BY weekday ASC, hour ASC;
|
ORDER BY hour ASC;
|
||||||
`);
|
`, [CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS])
|
||||||
|
]);
|
||||||
|
|
||||||
const purchaseWeekdays = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
|
const purchaseWeekdays = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
|
||||||
const purchaseHours = Array.from({ length: 24 }, (_, hour) => ({
|
const purchaseHours = Array.from({ length: 24 }, (_, hour) => ({
|
||||||
@@ -1036,14 +1061,18 @@ const getClientPurchasePatternAnalytics = async () => {
|
|||||||
value: 0
|
value: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
result.rows.forEach(row => {
|
weekdayResult.rows.forEach(row => {
|
||||||
const weekday = row.weekday === null || row.weekday === undefined ? null : Number(row.weekday);
|
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);
|
const orderCount = toNumber(row.order_count);
|
||||||
|
|
||||||
if (weekday !== null && Number.isInteger(weekday) && purchaseWeekdays[weekday]) {
|
if (weekday !== null && Number.isInteger(weekday) && purchaseWeekdays[weekday]) {
|
||||||
purchaseWeekdays[weekday].value += orderCount;
|
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]) {
|
if (hour !== null && Number.isInteger(hour) && purchaseHours[hour]) {
|
||||||
purchaseHours[hour].value += orderCount;
|
purchaseHours[hour].value += orderCount;
|
||||||
@@ -1052,7 +1081,9 @@ const getClientPurchasePatternAnalytics = async () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
purchaseWeekdays,
|
purchaseWeekdays,
|
||||||
purchaseHours
|
purchaseHours,
|
||||||
|
weekdayRangeLabel: 'Todo período',
|
||||||
|
hourRangeLabel: `Últimos ${CLIENT_PURCHASE_PATTERN_HOUR_LOOKBACK_DAYS} dias`
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 originalQuery = pool.query;
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
pool.query = async (sql, params = []) => {
|
pool.query = async (sql, params = []) => {
|
||||||
assert.deepEqual(params, []);
|
calls.push({ sql, params });
|
||||||
assert.match(sql, /order_events AS/);
|
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, /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/);
|
assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: [
|
rows: [
|
||||||
{ weekday: 1, hour: 9, order_count: 2 },
|
{ hour: 9, order_count: 2 },
|
||||||
{ weekday: 5, hour: 18, order_count: 1 },
|
{ hour: 18, order_count: 1 },
|
||||||
{ weekday: 5, hour: null, order_count: 1 }
|
{ hour: null, order_count: 1 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -853,6 +867,7 @@ test('getClientPurchasePatternAnalytics returns all-time weekday and hour counts
|
|||||||
try {
|
try {
|
||||||
const pattern = await getClientPurchasePatternAnalytics();
|
const pattern = await getClientPurchasePatternAnalytics();
|
||||||
|
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
assert.equal(pattern.purchaseWeekdays.length, 7);
|
assert.equal(pattern.purchaseWeekdays.length, 7);
|
||||||
assert.equal(pattern.purchaseHours.length, 24);
|
assert.equal(pattern.purchaseHours.length, 24);
|
||||||
assert.deepEqual(pattern.purchaseWeekdays[1], { label: 'Seg', value: 2 });
|
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[9], { label: '09h', value: 2 });
|
||||||
assert.deepEqual(pattern.purchaseHours[18], { label: '18h', value: 1 });
|
assert.deepEqual(pattern.purchaseHours[18], { label: '18h', value: 1 });
|
||||||
assert.deepEqual(pattern.purchaseHours[0], { label: '00h', value: 0 });
|
assert.deepEqual(pattern.purchaseHours[0], { label: '00h', value: 0 });
|
||||||
|
assert.equal(pattern.weekdayRangeLabel, 'Todo período');
|
||||||
|
assert.equal(pattern.hourRangeLabel, 'Últimos 60 dias');
|
||||||
} finally {
|
} finally {
|
||||||
pool.query = originalQuery;
|
pool.query = originalQuery;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,6 +301,8 @@ export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Parti
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emptyClientPurchasePattern = (): ClientPurchasePatternAnalytics => ({
|
const emptyClientPurchasePattern = (): ClientPurchasePatternAnalytics => ({
|
||||||
|
weekdayRangeLabel: 'Todo período',
|
||||||
|
hourRangeLabel: 'Últimos 60 dias',
|
||||||
purchaseWeekdays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'].map(label => ({ label, value: 0 })),
|
purchaseWeekdays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'].map(label => ({ label, value: 0 })),
|
||||||
purchaseHours: Array.from({ length: 24 }, (_, hour) => ({
|
purchaseHours: Array.from({ length: 24 }, (_, hour) => ({
|
||||||
label: `${String(hour).padStart(2, '0')}h`,
|
label: `${String(hour).padStart(2, '0')}h`,
|
||||||
|
|||||||
@@ -522,6 +522,8 @@ const Clients = () => {
|
|||||||
const shouldShowRfmLoading = isRfmLoading && clientAnalytics.length > 0;
|
const shouldShowRfmLoading = isRfmLoading && clientAnalytics.length > 0;
|
||||||
const hasWeekdayPattern = purchasePattern?.purchaseWeekdays.some(day => day.value > 0) ?? false;
|
const hasWeekdayPattern = purchasePattern?.purchaseWeekdays.some(day => day.value > 0) ?? false;
|
||||||
const hasHourPattern = purchasePattern?.purchaseHours.some(hour => hour.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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -831,18 +833,20 @@ const Clients = () => {
|
|||||||
<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>
|
||||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h2>
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h2>
|
||||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando os clientes costumam comprar.</p>
|
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando os clientes costumam 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">
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h3>
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h3>
|
||||||
|
<span className="shrink-0 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-input dark:text-dark-muted">
|
||||||
|
{weekdayRangeLabel}
|
||||||
|
</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={purchasePattern?.purchaseWeekdays || []} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
|
<BarChart data={purchasePattern?.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} />
|
||||||
@@ -865,9 +869,14 @@ const Clients = () => {
|
|||||||
</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">
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h3>
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h3>
|
||||||
|
<span className="shrink-0 rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-500 dark:border-dark-border dark:bg-dark-input dark:text-dark-muted">
|
||||||
|
{hourRangeLabel}
|
||||||
|
</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={purchasePattern?.purchaseHours || []} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
|
<BarChart data={purchasePattern?.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} />
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export interface ClientFilterOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientPurchasePatternAnalytics {
|
export interface ClientPurchasePatternAnalytics {
|
||||||
|
weekdayRangeLabel?: string;
|
||||||
|
hourRangeLabel?: string;
|
||||||
purchaseWeekdays: Array<{
|
purchaseWeekdays: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user