diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx
index 1d0fb43..4a77f49 100644
--- a/src/app/dashboard/kpis/page.tsx
+++ b/src/app/dashboard/kpis/page.tsx
@@ -87,6 +87,7 @@ export default async function KpisPage() {
sparse={sparse}
weeklyRows={weekly}
channelPoints={channelSeries.points}
+ dailyRows={daily}
/>
);
}
diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx
index ce12324..14c1c33 100644
--- a/src/components/MetricsDashboard.tsx
+++ b/src/components/MetricsDashboard.tsx
@@ -15,14 +15,20 @@ import {
CalendarRange,
} from 'lucide-react';
import {
+ AdDailyRow,
ChannelWeekPoint,
Delta,
MetricTotals,
formatEur,
formatNum,
formatPct,
+ channelTotalsFromDaily,
+ filterDailyByDayRange,
+ metricTotalsFromDaily,
+ resolveKpiDayRange,
sumWeekly,
KpiWeeklyRow,
+ KpiRangeKey,
} from '@/lib/metrics';
type Card = {
@@ -34,7 +40,7 @@ type Card = {
invert?: boolean;
};
-type RangeKey = '7d' | '30d' | 'month' | 'all';
+type RangeKey = KpiRangeKey;
function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
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({
label,
portfolioWide,
@@ -115,6 +98,7 @@ export default function MetricsDashboard({
sparse,
weeklyRows = [],
channelPoints = [],
+ dailyRows = [],
}: {
label: string;
portfolioWide: boolean;
@@ -130,19 +114,35 @@ export default function MetricsDashboard({
sparse: boolean;
weeklyRows?: KpiWeeklyRow[];
channelPoints?: ChannelWeekPoint[];
+ /** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
+ dailyRows?: AdDailyRow[];
}) {
const [range, setRange] = useState('month');
- const ranged = useMemo(
- () => filterWeeklyByRange(weeklyRows, range),
- [weeklyRows, range]
- );
- const periodTotals = useMemo(
- () => (ranged.length ? sumWeekly(ranged) : month),
- [ranged, month]
- );
+ const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
+
+ const rangedDaily = useMemo(() => {
+ if (!dailyRows.length) return [];
+ if (!dayWindow) return dailyRows;
+ return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
+ }, [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(() => {
+ if (rangedDaily.length) return channelTotalsFromDaily(rangedDaily);
const map = new Map<
string,
{ spend: number; leads: number; deals: number; revenue: number }
@@ -151,19 +151,9 @@ export default function MetricsDashboard({
range === 'all'
? channelPoints
: channelPoints.filter((p) => {
- const d = new Date(p.week);
- if (Number.isNaN(d.getTime())) return false;
- if (range === 'month') {
- 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;
+ const d = p.week;
+ if (!d || !dayWindow) return true;
+ return d >= dayWindow.since && d <= dayWindow.until;
});
for (const p of src) {
const cur = map.get(p.channel) || {
@@ -186,7 +176,11 @@ export default function MetricsDashboard({
roas: v.spend > 0 ? v.revenue / v.spend : 0,
}))
.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[] = [
{
@@ -247,7 +241,7 @@ export default function MetricsDashboard({
{portfolioWide
? ' · portafolio'
: ' · zoom cliente'}
- . Paid unificado ahora; CRM/GEO en siguientes fases.
+ . Periodo {rangeHint} (mismos días calendario que Ad Tracker).
@@ -348,9 +342,9 @@ export default function MetricsDashboard({
{channelRows.length === 0 ? (
- Sin series de canal en el periodo. Cuando Windsor cargue
- Los canales con inversión en ads (Meta, Google) aparecen cuando hay
- filas en ad_daily o kpi_weekly para este cliente.
+ Sin series de canal en el periodo. Los canales Meta/Google aparecen
+ cuando hay filas en ad_daily para este cliente (elige uno en
+ Contexto).
) : (
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts
index e1f22a2..e57d561 100644
--- a/src/lib/metrics.ts
+++ b/src/lib/metrics.ts
@@ -312,3 +312,84 @@ export function resolveWeeklyRows(
}
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();
+ 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,
+ };
+}