feat: metrics dashboard with WoW/MoM/YoY and creatives pipeline
Rebuild KPIs as a real dashboard (period deltas, tenant zoom), clarify objetivos/calendario/activos sources, and turn creatives into a status pipeline with delete. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
189
src/lib/metrics.ts
Normal file
189
src/lib/metrics.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/** KPI aggregation + WoW / MoM / YoY helpers. */
|
||||
|
||||
export type KpiWeeklyRow = {
|
||||
tenant_id?: string;
|
||||
week?: string;
|
||||
channel?: string;
|
||||
total_leads?: number;
|
||||
cohort_deals_won?: number;
|
||||
cohort_revenue_won?: number;
|
||||
total_spend?: number;
|
||||
cac_cohort?: number;
|
||||
roas_cohort?: number;
|
||||
};
|
||||
|
||||
export type AdDailyRow = {
|
||||
date?: string;
|
||||
spend?: number;
|
||||
platform_leads?: number;
|
||||
impressions?: number;
|
||||
clicks?: number;
|
||||
};
|
||||
|
||||
export type MetricTotals = {
|
||||
spend: number;
|
||||
leads: number;
|
||||
deals: number;
|
||||
revenue: number;
|
||||
cac: number;
|
||||
roas: number;
|
||||
cpl: number;
|
||||
};
|
||||
|
||||
export type Delta = {
|
||||
wow: number | null;
|
||||
mom: number | null;
|
||||
yoy: number | null;
|
||||
};
|
||||
|
||||
function n(v: unknown): number {
|
||||
const x = Number(v);
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
}
|
||||
|
||||
export function sumWeekly(rows: KpiWeeklyRow[]): MetricTotals {
|
||||
const spend = rows.reduce((a, r) => a + n(r.total_spend), 0);
|
||||
const leads = rows.reduce((a, r) => a + n(r.total_leads), 0);
|
||||
const deals = rows.reduce((a, r) => a + n(r.cohort_deals_won), 0);
|
||||
const revenue = rows.reduce((a, r) => a + n(r.cohort_revenue_won), 0);
|
||||
return {
|
||||
spend,
|
||||
leads,
|
||||
deals,
|
||||
revenue,
|
||||
cac: deals > 0 ? spend / deals : 0,
|
||||
roas: spend > 0 ? revenue / spend : 0,
|
||||
cpl: leads > 0 ? spend / leads : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function sumDaily(rows: AdDailyRow[]): {
|
||||
spend: number;
|
||||
leads: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
} {
|
||||
return {
|
||||
spend: rows.reduce((a, r) => a + n(r.spend), 0),
|
||||
leads: rows.reduce((a, r) => a + n(r.platform_leads), 0),
|
||||
impressions: rows.reduce((a, r) => a + n(r.impressions), 0),
|
||||
clicks: rows.reduce((a, r) => a + n(r.clicks), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function weekStart(d: Date): Date {
|
||||
const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const day = x.getUTCDay(); // 0 Sun
|
||||
const diff = day === 0 ? -6 : 1 - day; // Monday
|
||||
x.setUTCDate(x.getUTCDate() + diff);
|
||||
x.setUTCHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function parseWeek(w?: string): Date | null {
|
||||
if (!w) return null;
|
||||
const d = new Date(w);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function pctChange(current: number, previous: number): number | null {
|
||||
if (previous === 0) {
|
||||
if (current === 0) return 0;
|
||||
return null; // can't compute meaningfully
|
||||
}
|
||||
return ((current - previous) / Math.abs(previous)) * 100;
|
||||
}
|
||||
|
||||
function filterWeeks(
|
||||
rows: KpiWeeklyRow[],
|
||||
from: Date,
|
||||
toExclusive: Date
|
||||
): KpiWeeklyRow[] {
|
||||
return rows.filter((r) => {
|
||||
const d = parseWeek(r.week);
|
||||
if (!d) return false;
|
||||
return d >= from && d < toExclusive;
|
||||
});
|
||||
}
|
||||
|
||||
/** Build period totals from weekly rows for WoW / MoM / YoY. */
|
||||
export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||
currentWeek: MetricTotals;
|
||||
prevWeek: MetricTotals;
|
||||
currentMonth: MetricTotals;
|
||||
prevMonth: MetricTotals;
|
||||
currentMonthLastYear: MetricTotals;
|
||||
deltas: Record<keyof MetricTotals, Delta>;
|
||||
} {
|
||||
const now = new Date();
|
||||
const thisWeek = weekStart(now);
|
||||
const prevWeekStart = new Date(thisWeek);
|
||||
prevWeekStart.setUTCDate(prevWeekStart.getUTCDate() - 7);
|
||||
const nextWeek = new Date(thisWeek);
|
||||
nextWeek.setUTCDate(nextWeek.getUTCDate() + 7);
|
||||
|
||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
||||
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
||||
const yoyMonthStart = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), 1));
|
||||
const yoyMonthEnd = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
|
||||
|
||||
const currentWeek = sumWeekly(filterWeeks(rows, thisWeek, nextWeek));
|
||||
const prevWeek = sumWeekly(filterWeeks(rows, prevWeekStart, thisWeek));
|
||||
const currentMonth = sumWeekly(filterWeeks(rows, monthStart, nextMonth));
|
||||
const prevMonth = sumWeekly(filterWeeks(rows, prevMonthStart, monthStart));
|
||||
const currentMonthLastYear = sumWeekly(
|
||||
filterWeeks(rows, yoyMonthStart, yoyMonthEnd)
|
||||
);
|
||||
|
||||
const keys: (keyof MetricTotals)[] = [
|
||||
'spend',
|
||||
'leads',
|
||||
'deals',
|
||||
'revenue',
|
||||
'cac',
|
||||
'roas',
|
||||
'cpl',
|
||||
];
|
||||
const deltas = {} as Record<keyof MetricTotals, Delta>;
|
||||
for (const k of keys) {
|
||||
// For CAC/CPL lower is better — still show raw % change; UI can note it
|
||||
deltas[k] = {
|
||||
wow: pctChange(currentWeek[k], prevWeek[k]),
|
||||
mom: pctChange(currentMonth[k], prevMonth[k]),
|
||||
yoy: pctChange(currentMonth[k], currentMonthLastYear[k]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
currentWeek,
|
||||
prevWeek,
|
||||
currentMonth,
|
||||
prevMonth,
|
||||
currentMonthLastYear,
|
||||
deltas,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatEur(v: number): string {
|
||||
return `${v.toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})} €`;
|
||||
}
|
||||
|
||||
export function formatPct(v: number | null): string {
|
||||
if (v === null || Number.isNaN(v)) return '—';
|
||||
const sign = v > 0 ? '+' : '';
|
||||
return `${sign}${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatNum(v: number): string {
|
||||
return v.toLocaleString('es-ES', { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
export { isoDate, weekStart };
|
||||
70
src/lib/tenant-context.ts
Normal file
70
src/lib/tenant-context.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
homeTenantId,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export type ResolvedTenant = {
|
||||
tenantId: string | null;
|
||||
/** true = agency zoomed into one client */
|
||||
viaView: boolean;
|
||||
/** true = agency with no client selected → all tenants */
|
||||
portfolioWide: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export async function resolveActiveTenant(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
} | null): Promise<ResolvedTenant> {
|
||||
const role = userRole(user);
|
||||
const cookieStore = await cookies();
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value || null;
|
||||
|
||||
if (isAgencyRole(role) && viewId) {
|
||||
return {
|
||||
tenantId: viewId,
|
||||
viaView: true,
|
||||
portfolioWide: false,
|
||||
label: viewName || 'Cliente',
|
||||
};
|
||||
}
|
||||
if (isAgencyRole(role) && !viewId) {
|
||||
return {
|
||||
tenantId: null,
|
||||
viaView: false,
|
||||
portfolioWide: true,
|
||||
label: 'Portafolio (todos los clientes)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
tenantId: homeTenantId(user),
|
||||
viaView: false,
|
||||
portfolioWide: false,
|
||||
label:
|
||||
(user?.user_metadata?.company as string) ||
|
||||
(user?.app_metadata?.company as string) ||
|
||||
'Cliente',
|
||||
};
|
||||
}
|
||||
|
||||
export async function restGet<T = unknown>(
|
||||
path: string
|
||||
): Promise<{ ok: boolean; data: T | null; error?: string }> {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return { ok: false, data: null, error: 'no service role' };
|
||||
const r = await fetch(`${supabaseUrl()}${path}`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) {
|
||||
return { ok: false, data: null, error: await r.text() };
|
||||
}
|
||||
return { ok: true, data: (await r.json()) as T };
|
||||
}
|
||||
Reference in New Issue
Block a user