Add client purchase pattern analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s

This commit is contained in:
Cauê Faleiros
2026-07-01 10:25:07 -03:00
parent b99b60b32e
commit a26bc8813e
6 changed files with 284 additions and 3 deletions

View File

@@ -4,6 +4,7 @@ const {
getClientAnalytics, getClientAnalytics,
getClientDetailsAnalytics, getClientDetailsAnalytics,
getClientFilterOptions, getClientFilterOptions,
getClientPurchasePatternAnalytics,
getDashboardAnalytics, getDashboardAnalytics,
getProductAnalytics, getProductAnalytics,
getProductDetailsAnalytics, getProductDetailsAnalytics,
@@ -75,6 +76,15 @@ router.get('/analytics/clients/filters', verifyToken, async (req, res) => {
} }
}); });
router.get('/analytics/clients/purchase-pattern', verifyToken, async (req, res) => {
try {
res.json(await getClientPurchasePatternAnalytics());
} catch (error) {
console.error('Error fetching client purchase pattern analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => { router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
try { try {
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query)); const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));

View File

@@ -942,6 +942,58 @@ const getClientFilterOptions = async () => {
}; };
}; };
const getClientPurchasePatternAnalytics = async () => {
const result = await pool.query(`
${CUSTOMER_IDENTITY_CTE},
order_events AS (
SELECT DISTINCT ON (
${CUSTOMER_KEY_SQL},
COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text)
)
data_pedido_date,
created_at
FROM identity_orders
WHERE data_pedido_date IS NOT NULL
ORDER BY
${CUSTOMER_KEY_SQL},
COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text),
created_at ASC NULLS LAST
)
SELECT
EXTRACT(DOW FROM data_pedido_date)::int as weekday,
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;
`);
const purchaseWeekdays = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
const purchaseHours = Array.from({ length: 24 }, (_, hour) => ({
label: `${String(hour).padStart(2, '0')}h`,
value: 0
}));
result.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;
}
if (hour !== null && Number.isInteger(hour) && purchaseHours[hour]) {
purchaseHours[hour].value += orderCount;
}
});
return {
purchaseWeekdays,
purchaseHours
};
};
const getOrderGroupKey = (row) => ( const getOrderGroupKey = (row) => (
row.pedido_id || row.pedido_id ||
`${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}` `${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}`
@@ -1353,6 +1405,7 @@ module.exports = {
getFrequencyScore, getFrequencyScore,
getClientDetailsAnalytics, getClientDetailsAnalytics,
getClientFilterOptions, getClientFilterOptions,
getClientPurchasePatternAnalytics,
getPreviousDate, getPreviousDate,
getRecencyScore, getRecencyScore,
getRfmAnalytics, getRfmAnalytics,

View File

@@ -11,6 +11,7 @@ const {
getClientAnalytics, getClientAnalytics,
getClientDetailsAnalytics, getClientDetailsAnalytics,
getClientFilterOptions, getClientFilterOptions,
getClientPurchasePatternAnalytics,
getDashboardAnalytics, getDashboardAnalytics,
getPreviousDate, getPreviousDate,
getProductAnalytics, getProductAnalytics,
@@ -791,6 +792,40 @@ test('getClientFilterOptions returns all distinct order metadata options', async
} }
}); });
test('getClientPurchasePatternAnalytics returns all-time weekday and hour counts', async () => {
const originalQuery = pool.query;
pool.query = async (sql, params = []) => {
assert.deepEqual(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/);
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 }
]
};
};
try {
const pattern = await getClientPurchasePatternAnalytics();
assert.equal(pattern.purchaseWeekdays.length, 7);
assert.equal(pattern.purchaseHours.length, 24);
assert.deepEqual(pattern.purchaseWeekdays[1], { label: 'Seg', value: 2 });
assert.deepEqual(pattern.purchaseWeekdays[5], { label: 'Sex', value: 2 });
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 });
} finally {
pool.query = originalQuery;
}
});
test('getClientDetailsAnalytics resolves legacy name tokens to the canonical phone client', async () => { test('getClientDetailsAnalytics resolves legacy name tokens to the canonical phone client', async () => {
const originalQuery = pool.query; const originalQuery = pool.query;
const calls = []; const calls = [];

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types'; import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -300,6 +300,28 @@ export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Parti
}, options); }, options);
}; };
const emptyClientPurchasePattern = (): ClientPurchasePatternAnalytics => ({
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`,
value: 0
}))
});
export const fetchClientPurchasePatternAnalytics = async (options?: CacheOptions): Promise<ClientPurchasePatternAnalytics> => {
const path = '/analytics/clients/purchase-pattern';
return getCachedAnalytics(path, async () => {
try {
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
if (!response.ok) return emptyClientPurchasePattern();
return await response.json();
} catch (error) {
console.error('Fetch client purchase pattern analytics failed', error);
return emptyClientPurchasePattern();
}
}, options);
};
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => { export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`; const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => { return getCachedAnalytics(path, async () => {

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react'; import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react';
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types'; import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, DateRange, RfmAnalytics, RfmClient } from '../types';
import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService'; import { fetchClientAnalytics, fetchClientFilterOptions, fetchClientPurchasePatternAnalytics, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
import type { ClientSortOption, ClientSummary } from '../analytics/clients'; import type { ClientSortOption, ClientSummary } from '../analytics/clients';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
@@ -133,6 +134,55 @@ const dateFilterPresets = [
]; ];
const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer"; const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer";
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
const WEEKDAY_BAR_COLOR = '#25C2FF';
const HOUR_BAR_COLOR = '#52DFA0';
type PatternTooltipProps = {
active?: boolean;
payload?: Array<{ value: number }>;
label?: string;
};
const PatternTooltip = ({ active, payload, label }: PatternTooltipProps) => {
if (!active || !payload?.length) return null;
const value = payload[0].value;
return (
<div className="rounded-xl bg-dark-card p-3 shadow-lg">
<p className="mb-1 font-bold text-brand-primary">{label}</p>
<p className="m-0 text-dark-text">
{value} {value === 1 ? 'pedido' : 'pedidos'}
</p>
</div>
);
};
const ClientPurchasePatternSkeleton = () => (
<section className="space-y-4" aria-label="Carregando padrão de compra">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="skeleton h-5 w-44" />
<div className="skeleton mt-2 h-4 w-64" />
</div>
<div className="skeleton h-7 w-28 rounded-full" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{[0, 1].map(item => (
<div key={`clients-pattern-skeleton-${item}`} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="skeleton h-4 w-40" />
<div className="mt-5 flex h-56 items-end gap-3">
{[0, 1, 2, 3, 4, 5, 6].map(bar => (
<div key={`clients-pattern-bar-skeleton-${item}-${bar}`} className="skeleton flex-1" style={{ height: `${20 + ((bar * 17) % 65)}%` }} />
))}
</div>
</div>
))}
</div>
</section>
);
const ClientsTableSkeleton = () => ( const ClientsTableSkeleton = () => (
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando clientes"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando clientes">
@@ -189,6 +239,8 @@ const Clients = () => {
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []); const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
const [isLoading, setIsLoading] = useState(!initialClientAnalytics); const [isLoading, setIsLoading] = useState(!initialClientAnalytics);
const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics)); const [isRfmLoading, setIsRfmLoading] = useState(!initialRfmAnalytics && Boolean(initialClientAnalytics));
const [purchasePattern, setPurchasePattern] = useState<ClientPurchasePatternAnalytics | null>(null);
const [isPatternLoading, setIsPatternLoading] = useState(true);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -237,6 +289,26 @@ const Clients = () => {
}; };
}, [dateRange, metadataFilters]); }, [dateRange, metadataFilters]);
useEffect(() => {
let isMounted = true;
const loadPurchasePattern = async () => {
setIsPatternLoading(true);
const nextPattern = await fetchClientPurchasePatternAnalytics();
if (isMounted) {
setPurchasePattern(nextPattern);
setIsPatternLoading(false);
}
};
void loadPurchasePattern();
return () => {
isMounted = false;
};
}, []);
useEffect(() => { useEffect(() => {
if (!isFilterMenuOpen) return; if (!isFilterMenuOpen) return;
@@ -446,6 +518,8 @@ const Clients = () => {
const hasActiveFilters = activeFilterCount > 0; const hasActiveFilters = activeFilterCount > 0;
const isRefreshing = isLoading && clientAnalytics.length > 0; const isRefreshing = isLoading && clientAnalytics.length > 0;
const shouldShowRfmLoading = isRfmLoading && clientAnalytics.length > 0; 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;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -773,6 +847,82 @@ const Clients = () => {
</div> </div>
</div> </div>
)} )}
{isPatternLoading ? (
<ClientPurchasePatternSkeleton />
) : (
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<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>
</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">
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h3>
{hasWeekdayPattern ? (
<div className="mt-5 h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={purchasePattern?.purchaseWeekdays || []} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis dataKey="label" stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} animationDuration={850} animationEasing="ease-out">
{(purchasePattern?.purchaseWeekdays || []).map(day => (
<Cell key={`clients-weekday-${day.label}`} fill={WEEKDAY_BAR_COLOR} fillOpacity={0.62} stroke={WEEKDAY_BAR_COLOR} strokeOpacity={0.9} strokeWidth={1.25} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex h-56 items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Sem compras registradas.
</div>
)}
</div>
<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>
{hasHourPattern ? (
<div className="mt-5 h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={purchasePattern?.purchaseHours || []} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="label"
stroke={CHART_AXIS_COLOR}
fontSize={10}
tickLine={false}
axisLine={false}
interval={0}
tickFormatter={(value) => Number(String(value).replace('h', '')) % 3 === 0 ? String(value) : ''}
/>
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} animationDuration={850} animationEasing="ease-out">
{(purchasePattern?.purchaseHours || []).map(hour => (
<Cell key={`clients-hour-${hour.label}`} fill={HOUR_BAR_COLOR} fillOpacity={0.56} stroke={HOUR_BAR_COLOR} strokeOpacity={0.86} strokeWidth={1.1} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</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.
</div>
)}
</div>
</div>
</section>
)}
</div> </div>
); );
}; };

View File

@@ -153,6 +153,17 @@ export interface ClientFilterOptions {
sellers: ClientSellerFilterOption[]; sellers: ClientSellerFilterOption[];
} }
export interface ClientPurchasePatternAnalytics {
purchaseWeekdays: Array<{
label: string;
value: number;
}>;
purchaseHours: Array<{
label: string;
value: number;
}>;
}
export interface RfmClient { export interface RfmClient {
customerKey: string; customerKey: string;
clientToken: string; clientToken: string;