Align Ads and Métricas on the same paginated ad_daily warehouse.

Ad Tracker only loaded 2000 newest rows, so Brava showed 0 € while Métricas summed the full month. Both now page the same table for the selected range.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 15:33:06 +02:00
parent fac9867202
commit a5bfec698b
6 changed files with 181 additions and 74 deletions

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { fetchAdDailyPaged } from '@/lib/fetch-ad-daily';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json({
rows: [],
warning: 'Selecciona un cliente en Contexto',
});
}
const since = req.nextUrl.searchParams.get('since') || '';
const until = req.nextUrl.searchParams.get('until') || '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
return NextResponse.json({ error: 'since y until (YYYY-MM-DD)' }, { status: 400 });
}
const agency = isAgencyRole(userRole(user));
const rows = await fetchAdDailyPaged({
tenantId: ctx.tenantId,
useServiceRole: agency && ctx.viaView,
since,
until,
});
return NextResponse.json({
rows,
since,
until,
tenant_label: ctx.label,
});
}

View File

@@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { resolveActiveTenant } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency'; import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow } from '@/lib/metrics'; import { AdDailyRow } from '@/lib/metrics';
import AdTrackerClient from '@/components/AdTrackerClient'; import AdTrackerClient from '@/components/AdTrackerClient';
import { fetchAdDailyPaged, adDailyLookbackSince } from '@/lib/fetch-ad-daily';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -41,19 +42,12 @@ export default async function AdTrackerPage() {
} }
let daily: AdDailyRow[] = []; let daily: AdDailyRow[] = [];
if (agency && ctx.viaView && ctx.tenantId) { if (ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId); daily = await fetchAdDailyPaged({
const d = await restGet<AdDailyRow[]>( tenantId: ctx.tenantId,
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=2000` useServiceRole: agency && ctx.viaView,
); since: adDailyLookbackSince(),
daily = d.data || []; });
} else {
const { data } = await supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(2000);
daily = (data as AdDailyRow[]) || [];
} }
return ( return (

View File

@@ -18,59 +18,13 @@ import {
} from '@/lib/metrics'; } from '@/lib/metrics';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { OrganicDailyRow } from '@/lib/organic'; import { OrganicDailyRow } from '@/lib/organic';
import {
adDailyLookbackSince,
fetchAdDailyPaged,
} from '@/lib/fetch-ad-daily';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/** Enough history for YoY from Jan 2025 (inclusive). No artificial 700d floor. */
const AD_DAILY_LOOKBACK_DAYS =
Math.ceil((Date.now() - Date.UTC(2025, 0, 1)) / 86400000) + 14;
const AD_DAILY_PAGE = 1000;
async function fetchAdDaily(
tenantId: string | null,
useServiceRole: boolean
): Promise<AdDailyRow[]> {
const since = new Date();
since.setUTCDate(since.getUTCDate() - AD_DAILY_LOOKBACK_DAYS);
const sinceStr = since.toISOString().slice(0, 10);
const out: AdDailyRow[] = [];
if (useServiceRole) {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return [];
const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: '';
for (let offset = 0; offset < 200000; offset += AD_DAILY_PAGE) {
const r = await fetch(
`${base}/rest/v1/ad_daily?select=*&date=gte.${sinceStr}${tidFilter}&order=date.desc&limit=${AD_DAILY_PAGE}&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!r.ok) break;
const chunk = (await r.json()) as AdDailyRow[];
out.push(...chunk);
if (chunk.length < AD_DAILY_PAGE) break;
}
return out;
}
const supabase = await createClient();
for (let offset = 0; offset < 200000; offset += AD_DAILY_PAGE) {
const { data, error } = await supabase
.from('ad_daily')
.select('*')
.gte('date', sinceStr)
.order('date', { ascending: false })
.range(offset, offset + AD_DAILY_PAGE - 1);
if (error) break;
const chunk = (data as AdDailyRow[]) || [];
out.push(...chunk);
if (chunk.length < AD_DAILY_PAGE) break;
}
return out;
}
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */ /** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
async function fetchCrmLeads( async function fetchCrmLeads(
tenantId: string | null, tenantId: string | null,
@@ -344,7 +298,11 @@ export default async function KpisPage() {
'/rest/v1/kpi_weekly?select=*&order=week.desc' '/rest/v1/kpi_weekly?select=*&order=week.desc'
); );
weekly = w.data || []; weekly = w.data || [];
daily = await fetchAdDaily(null, true); daily = await fetchAdDailyPaged({
tenantId: null,
useServiceRole: true,
since: adDailyLookbackSince(),
});
crmLeads = []; crmLeads = [];
wonDeals = await fetchWonDeals(null, true); wonDeals = await fetchWonDeals(null, true);
organic = await fetchOrganicDaily(null, true); organic = await fetchOrganicDaily(null, true);
@@ -355,7 +313,11 @@ export default async function KpisPage() {
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc` `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc`
); );
weekly = w.data || []; weekly = w.data || [];
daily = await fetchAdDaily(ctx.tenantId, true); daily = await fetchAdDailyPaged({
tenantId: ctx.tenantId,
useServiceRole: true,
since: adDailyLookbackSince(),
});
crmLeads = await fetchCrmLeads(ctx.tenantId, true); crmLeads = await fetchCrmLeads(ctx.tenantId, true);
wonDeals = await fetchWonDeals(ctx.tenantId, true); wonDeals = await fetchWonDeals(ctx.tenantId, true);
organic = await fetchOrganicDaily(ctx.tenantId, true); organic = await fetchOrganicDaily(ctx.tenantId, true);
@@ -367,7 +329,11 @@ export default async function KpisPage() {
.select('*') .select('*')
.order('week', { ascending: false }); .order('week', { ascending: false });
weekly = (w as KpiWeeklyRow[]) || []; weekly = (w as KpiWeeklyRow[]) || [];
daily = await fetchAdDaily(ctx.tenantId, false); daily = await fetchAdDailyPaged({
tenantId: ctx.tenantId,
useServiceRole: false,
since: adDailyLookbackSince(),
});
crmLeads = await fetchCrmLeads(ctx.tenantId, false); crmLeads = await fetchCrmLeads(ctx.tenantId, false);
wonDeals = await fetchWonDeals(ctx.tenantId, false); wonDeals = await fetchWonDeals(ctx.tenantId, false);
organic = await fetchOrganicDaily(ctx.tenantId, false); organic = await fetchOrganicDaily(ctx.tenantId, false);

View File

@@ -68,7 +68,9 @@ function rates(input: Omit<PlatformKpis, 'ctr' | 'cpc' | 'cpl'>): PlatformKpis {
} }
function convOf(r: AdDailyRow): number { function convOf(r: AdDailyRow): number {
return Number(r.conversions ?? r.platform_leads ?? 0) || 0; const x = r.platform_leads ?? r.conversions;
const n = Number(x);
return Number.isFinite(n) ? n : 0;
} }
function campaignName(r: AdDailyRow): string { function campaignName(r: AdDailyRow): string {
@@ -541,12 +543,44 @@ export default function AdTrackerClient({
} | null>(null); } | null>(null);
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null); const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
const [rowsLive, setRowsLive] = useState<AdDailyRow[]>(rows);
const [rowsLoading, setRowsLoading] = useState(false);
const range = useMemo(() => ({ since, until, label: rangeLabel }), [ const range = useMemo(() => ({ since, until, label: rangeLabel }), [
since, since,
until, until,
rangeLabel, rangeLabel,
]); ]);
useEffect(() => {
setRowsLive(rows);
}, [rows]);
useEffect(() => {
let cancelled = false;
async function loadRange() {
setRowsLoading(true);
try {
const qs = new URLSearchParams({
since: range.since,
until: range.until,
});
const r = await fetch(`/api/ads/daily?${qs}`, { cache: 'no-store' });
const data = await r.json().catch(() => ({}));
if (cancelled) return;
if (r.ok && Array.isArray(data.rows)) {
setRowsLive(data.rows as AdDailyRow[]);
}
} finally {
if (!cancelled) setRowsLoading(false);
}
}
void loadRange();
return () => {
cancelled = true;
};
}, [range.since, range.until]);
useEffect(() => { useEffect(() => {
if (tab === 'resumen') { if (tab === 'resumen') {
setCreatives([]); setCreatives([]);
@@ -636,8 +670,8 @@ export default function AdTrackerClient({
}, [range.since, range.until, tab]); }, [range.since, range.until, tab]);
const filtered = useMemo( const filtered = useMemo(
() => rows.filter((r) => inRange(r.date, range.since, range.until)), () => rowsLive.filter((r) => inRange(r.date, range.since, range.until)),
[rows, range.since, range.until] [rowsLive, range.since, range.until]
); );
const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta'); const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta');
@@ -694,7 +728,9 @@ export default function AdTrackerClient({
<PageHeader <PageHeader
eyebrow="Publicidad" eyebrow="Publicidad"
title={tagline || 'Ads'} title={tagline || 'Ads'}
description={`${label} · Meta + Google · ${rangeLabel}`} description={`${label} · Meta + Google · ${rangeLabel}${
rowsLoading ? ' · actualizando…' : ''
}`}
actions={<SyncNowButton forceModule="ads" />} actions={<SyncNowButton forceModule="ads" />}
/> />

View File

@@ -361,8 +361,10 @@ export default function MetricsDashboard({
const periodTotals = useMemo(() => { const periodTotals = useMemo(() => {
let base: MetricTotals; let base: MetricTotals;
if (rangedDaily.length) { if (dailyRows.length) {
base = metricTotalsFromDaily(rangedDaily); base = rangedDaily.length
? metricTotalsFromDaily(rangedDaily)
: metricTotalsFromDaily([]);
} else { } else {
const ranged = weeklyRows.filter((r) => { const ranged = weeklyRows.filter((r) => {
if (!r.week) return false; if (!r.week) return false;
@@ -372,7 +374,7 @@ export default function MetricsDashboard({
base = ranged.length ? sumWeekly(ranged) : month; base = ranged.length ? sumWeekly(ranged) : month;
} }
return withCohortFromDeals(base, rangedDeals); return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]); }, [dailyRows.length, rangedDaily, rangedDeals, weeklyRows, since, until, month]);
const rangeDeltas = useMemo( const rangeDeltas = useMemo(
() => buildRangeDeltas(dailyRows, wonDeals, since, until), () => buildRangeDeltas(dailyRows, wonDeals, since, until),

63
src/lib/fetch-ad-daily.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createClient } from '@/lib/supabase/server';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import type { AdDailyRow } from '@/lib/metrics';
const PAGE = 1000;
const MAX_ROWS = 200000;
export async function fetchAdDailyPaged(opts: {
tenantId: string | null;
useServiceRole: boolean;
since: string;
until?: string;
}): Promise<AdDailyRow[]> {
const { tenantId, useServiceRole, since, until } = opts;
const out: AdDailyRow[] = [];
if (useServiceRole) {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return [];
const tid = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: '';
const untilF = until ? `&date=lte.${until}` : '';
for (let offset = 0; offset < MAX_ROWS; offset += PAGE) {
const r = await fetch(
`${base}/rest/v1/ad_daily?select=*&date=gte.${since}${untilF}${tid}&order=date.desc&limit=${PAGE}&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!r.ok) break;
const chunk = (await r.json()) as AdDailyRow[];
out.push(...chunk);
if (chunk.length < PAGE) break;
}
return out;
}
const supabase = await createClient();
for (let offset = 0; offset < MAX_ROWS; offset += PAGE) {
let q = supabase
.from('ad_daily')
.select('*')
.gte('date', since)
.order('date', { ascending: false })
.range(offset, offset + PAGE - 1);
if (until) q = q.lte('date', until);
const { data, error } = await q;
if (error) break;
const chunk = (data as AdDailyRow[]) || [];
out.push(...chunk);
if (chunk.length < PAGE) break;
}
return out;
}
export function adDailyLookbackSince(): string {
const since = new Date();
since.setUTCDate(
since.getUTCDate() -
(Math.ceil((Date.now() - Date.UTC(2025, 0, 1)) / 86400000) + 14)
);
return since.toISOString().slice(0, 10);
}