fix: alinear KPIs con Ad Tracker por días calendario
KPIs filtraba por inicio de semana (omitía días de Sep en la semana del 31-ago); ahora Mes/7d/30d suman ad_daily día a día como Ad Tracker. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -87,6 +87,7 @@ export default async function KpisPage() {
|
|||||||
sparse={sparse}
|
sparse={sparse}
|
||||||
weeklyRows={weekly}
|
weeklyRows={weekly}
|
||||||
channelPoints={channelSeries.points}
|
channelPoints={channelSeries.points}
|
||||||
|
dailyRows={daily}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,20 @@ import {
|
|||||||
CalendarRange,
|
CalendarRange,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
|
AdDailyRow,
|
||||||
ChannelWeekPoint,
|
ChannelWeekPoint,
|
||||||
Delta,
|
Delta,
|
||||||
MetricTotals,
|
MetricTotals,
|
||||||
formatEur,
|
formatEur,
|
||||||
formatNum,
|
formatNum,
|
||||||
formatPct,
|
formatPct,
|
||||||
|
channelTotalsFromDaily,
|
||||||
|
filterDailyByDayRange,
|
||||||
|
metricTotalsFromDaily,
|
||||||
|
resolveKpiDayRange,
|
||||||
sumWeekly,
|
sumWeekly,
|
||||||
KpiWeeklyRow,
|
KpiWeeklyRow,
|
||||||
|
KpiRangeKey,
|
||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
|
|
||||||
type Card = {
|
type Card = {
|
||||||
@@ -34,7 +40,7 @@ type Card = {
|
|||||||
invert?: boolean;
|
invert?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RangeKey = '7d' | '30d' | 'month' | 'all';
|
type RangeKey = KpiRangeKey;
|
||||||
|
|
||||||
function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
||||||
const cells: { label: string; v: number | null }[] = [
|
const cells: { label: string; v: number | null }[] = [
|
||||||
@@ -82,29 +88,6 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterWeeklyByRange(
|
|
||||||
rows: KpiWeeklyRow[],
|
|
||||||
range: RangeKey
|
|
||||||
): KpiWeeklyRow[] {
|
|
||||||
if (range === 'all') return rows;
|
|
||||||
const now = new Date();
|
|
||||||
let from: Date;
|
|
||||||
if (range === '7d') {
|
|
||||||
from = new Date(now);
|
|
||||||
from.setUTCDate(from.getUTCDate() - 7);
|
|
||||||
} else if (range === '30d') {
|
|
||||||
from = new Date(now);
|
|
||||||
from.setUTCDate(from.getUTCDate() - 30);
|
|
||||||
} else {
|
|
||||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
||||||
}
|
|
||||||
return rows.filter((r) => {
|
|
||||||
if (!r.week) return false;
|
|
||||||
const d = new Date(r.week);
|
|
||||||
return !Number.isNaN(d.getTime()) && d >= from;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MetricsDashboard({
|
export default function MetricsDashboard({
|
||||||
label,
|
label,
|
||||||
portfolioWide,
|
portfolioWide,
|
||||||
@@ -115,6 +98,7 @@ export default function MetricsDashboard({
|
|||||||
sparse,
|
sparse,
|
||||||
weeklyRows = [],
|
weeklyRows = [],
|
||||||
channelPoints = [],
|
channelPoints = [],
|
||||||
|
dailyRows = [],
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
portfolioWide: boolean;
|
portfolioWide: boolean;
|
||||||
@@ -130,19 +114,35 @@ export default function MetricsDashboard({
|
|||||||
sparse: boolean;
|
sparse: boolean;
|
||||||
weeklyRows?: KpiWeeklyRow[];
|
weeklyRows?: KpiWeeklyRow[];
|
||||||
channelPoints?: ChannelWeekPoint[];
|
channelPoints?: ChannelWeekPoint[];
|
||||||
|
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
|
||||||
|
dailyRows?: AdDailyRow[];
|
||||||
}) {
|
}) {
|
||||||
const [range, setRange] = useState<RangeKey>('month');
|
const [range, setRange] = useState<RangeKey>('month');
|
||||||
|
|
||||||
const ranged = useMemo(
|
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
|
||||||
() => filterWeeklyByRange(weeklyRows, range),
|
|
||||||
[weeklyRows, range]
|
const rangedDaily = useMemo(() => {
|
||||||
);
|
if (!dailyRows.length) return [];
|
||||||
const periodTotals = useMemo(
|
if (!dayWindow) return dailyRows;
|
||||||
() => (ranged.length ? sumWeekly(ranged) : month),
|
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
||||||
[ranged, month]
|
}, [dailyRows, dayWindow]);
|
||||||
);
|
|
||||||
|
const periodTotals = useMemo(() => {
|
||||||
|
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
|
||||||
|
// Fallback when no ad_daily: approximate from weekly (legacy)
|
||||||
|
if (!dayWindow) return sumWeekly(weeklyRows);
|
||||||
|
const from = dayWindow.since;
|
||||||
|
const until = dayWindow.until;
|
||||||
|
const ranged = weeklyRows.filter((r) => {
|
||||||
|
if (!r.week) return false;
|
||||||
|
const w = r.week.slice(0, 10);
|
||||||
|
return w <= until && w >= from;
|
||||||
|
});
|
||||||
|
return ranged.length ? sumWeekly(ranged) : month;
|
||||||
|
}, [rangedDaily, weeklyRows, dayWindow, month]);
|
||||||
|
|
||||||
const channelRows = useMemo(() => {
|
const channelRows = useMemo(() => {
|
||||||
|
if (rangedDaily.length) return channelTotalsFromDaily(rangedDaily);
|
||||||
const map = new Map<
|
const map = new Map<
|
||||||
string,
|
string,
|
||||||
{ spend: number; leads: number; deals: number; revenue: number }
|
{ spend: number; leads: number; deals: number; revenue: number }
|
||||||
@@ -151,19 +151,9 @@ export default function MetricsDashboard({
|
|||||||
range === 'all'
|
range === 'all'
|
||||||
? channelPoints
|
? channelPoints
|
||||||
: channelPoints.filter((p) => {
|
: channelPoints.filter((p) => {
|
||||||
const d = new Date(p.week);
|
const d = p.week;
|
||||||
if (Number.isNaN(d.getTime())) return false;
|
if (!d || !dayWindow) return true;
|
||||||
if (range === 'month') {
|
return d >= dayWindow.since && d <= dayWindow.until;
|
||||||
const now = new Date();
|
|
||||||
return (
|
|
||||||
d.getUTCFullYear() === now.getUTCFullYear() &&
|
|
||||||
d.getUTCMonth() === now.getUTCMonth()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const now = new Date();
|
|
||||||
const from = new Date(now);
|
|
||||||
from.setUTCDate(from.getUTCDate() - (range === '7d' ? 7 : 30));
|
|
||||||
return d >= from;
|
|
||||||
});
|
});
|
||||||
for (const p of src) {
|
for (const p of src) {
|
||||||
const cur = map.get(p.channel) || {
|
const cur = map.get(p.channel) || {
|
||||||
@@ -186,7 +176,11 @@ export default function MetricsDashboard({
|
|||||||
roas: v.spend > 0 ? v.revenue / v.spend : 0,
|
roas: v.spend > 0 ? v.revenue / v.spend : 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.spend - a.spend);
|
.sort((a, b) => b.spend - a.spend);
|
||||||
}, [channelPoints, range]);
|
}, [rangedDaily, channelPoints, range, dayWindow]);
|
||||||
|
|
||||||
|
const rangeHint = dayWindow
|
||||||
|
? `${dayWindow.since} → ${dayWindow.until}`
|
||||||
|
: 'todo el histórico';
|
||||||
|
|
||||||
const cards: Card[] = [
|
const cards: Card[] = [
|
||||||
{
|
{
|
||||||
@@ -247,7 +241,7 @@ export default function MetricsDashboard({
|
|||||||
{portfolioWide
|
{portfolioWide
|
||||||
? ' · portafolio'
|
? ' · portafolio'
|
||||||
: ' · zoom cliente'}
|
: ' · zoom cliente'}
|
||||||
. Paid unificado ahora; CRM/GEO en siguientes fases.
|
. Periodo {rangeHint} (mismos días calendario que Ad Tracker).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -348,9 +342,9 @@ export default function MetricsDashboard({
|
|||||||
</div>
|
</div>
|
||||||
{channelRows.length === 0 ? (
|
{channelRows.length === 0 ? (
|
||||||
<p className="text-xs text-gray-500 py-6 text-center">
|
<p className="text-xs text-gray-500 py-6 text-center">
|
||||||
Sin series de canal en el periodo. Cuando Windsor cargue
|
Sin series de canal en el periodo. Los canales Meta/Google aparecen
|
||||||
Los canales con inversión en ads (Meta, Google) aparecen cuando hay
|
cuando hay filas en ad_daily para este cliente (elige uno en
|
||||||
filas en ad_daily o kpi_weekly para este cliente.
|
Contexto).
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
@@ -312,3 +312,84 @@ export function resolveWeeklyRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { isoDate, weekStart };
|
export { isoDate, weekStart };
|
||||||
|
|
||||||
|
/** Same calendar windows as Ad Tracker (inclusive day bounds, UTC). */
|
||||||
|
export type KpiRangeKey = '7d' | '30d' | 'month' | 'all';
|
||||||
|
|
||||||
|
export function resolveKpiDayRange(
|
||||||
|
range: KpiRangeKey,
|
||||||
|
now = new Date()
|
||||||
|
): { since: string; until: string } | null {
|
||||||
|
const today = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||||
|
);
|
||||||
|
const until = isoDate(today);
|
||||||
|
if (range === 'all') return null;
|
||||||
|
if (range === 'month') {
|
||||||
|
const since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1));
|
||||||
|
return { since: isoDate(since), until };
|
||||||
|
}
|
||||||
|
const since = new Date(today);
|
||||||
|
// Inclusive windows: 7d = today-6..today, 30d = today-29..today
|
||||||
|
since.setUTCDate(since.getUTCDate() - (range === '7d' ? 6 : 29));
|
||||||
|
return { since: isoDate(since), until };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDailyByDayRange(
|
||||||
|
rows: AdDailyRow[],
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): AdDailyRow[] {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
const d = (r.date || '').slice(0, 10);
|
||||||
|
return d >= since && d <= until;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-channel paid totals from ad_daily (calendar days). */
|
||||||
|
export function channelTotalsFromDaily(rows: AdDailyRow[]): {
|
||||||
|
channel: string;
|
||||||
|
spend: number;
|
||||||
|
leads: number;
|
||||||
|
deals: number;
|
||||||
|
revenue: number;
|
||||||
|
cpl: number;
|
||||||
|
roas: number;
|
||||||
|
}[] {
|
||||||
|
const map = new Map<string, { spend: number; leads: number }>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const channel = (r.platform || 'otros').toLowerCase();
|
||||||
|
const cur = map.get(channel) || { spend: 0, leads: 0 };
|
||||||
|
cur.spend += n(r.spend);
|
||||||
|
cur.leads += n(r.platform_leads ?? r.conversions);
|
||||||
|
map.set(channel, cur);
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([channel, v]) => ({
|
||||||
|
channel,
|
||||||
|
spend: v.spend,
|
||||||
|
leads: v.leads,
|
||||||
|
deals: 0,
|
||||||
|
revenue: 0,
|
||||||
|
cpl: v.leads > 0 ? v.spend / v.leads : 0,
|
||||||
|
roas: 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.spend - a.spend);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metricTotalsFromDaily(rows: AdDailyRow[]): MetricTotals {
|
||||||
|
const spend = rows.reduce((a, r) => a + n(r.spend), 0);
|
||||||
|
const leads = rows.reduce(
|
||||||
|
(a, r) => a + n(r.platform_leads ?? r.conversions),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
spend,
|
||||||
|
leads,
|
||||||
|
deals: 0,
|
||||||
|
revenue: 0,
|
||||||
|
cac: 0,
|
||||||
|
roas: 0,
|
||||||
|
cpl: leads > 0 ? spend / leads : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user