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:
4Ventures
2026-09-23 12:38:45 +02:00
parent fba025da76
commit fb313fc38f
3 changed files with 126 additions and 50 deletions

View File

@@ -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}
/> />
); );
} }

View File

@@ -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">

View File

@@ -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,
};
}