feat: rango de fechas personalizado y datos hasta ayer (salvo Hoy)

Selector libre en Ad Tracker/Seguimiento; presets y KPIs cierran en ayer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 10:24:25 +02:00
parent 068faa3d25
commit 8fda2996b6
5 changed files with 299 additions and 73 deletions

View File

@@ -2,7 +2,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
AD_TRACKER_PRESETS,
RangePreset, RangePreset,
normalizePlatform, normalizePlatform,
resolveAdRange, resolveAdRange,
@@ -14,6 +13,7 @@ import {
CreativeZoomModal, CreativeZoomModal,
} from '@/components/AdCreativesGallery'; } from '@/components/AdCreativesGallery';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import DateRangePicker from '@/components/DateRangePicker';
type PlatformKpis = { type PlatformKpis = {
spend: number; spend: number;
@@ -318,6 +318,8 @@ export default function AdTrackerClient({
rows: AdDailyRow[]; rows: AdDailyRow[];
}) { }) {
const [preset, setPreset] = useState<RangePreset>('last_7d'); const [preset, setPreset] = useState<RangePreset>('last_7d');
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState('');
const [tab, setTab] = useState<Tab>('resumen'); const [tab, setTab] = useState<Tab>('resumen');
const [chartMode, setChartMode] = useState<ChartMode>('combined'); const [chartMode, setChartMode] = useState<ChartMode>('combined');
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]); const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
@@ -325,7 +327,10 @@ export default function AdTrackerClient({
const [creativesWarning, setCreativesWarning] = useState<string | null>(null); const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null); const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
const range = useMemo(() => resolveAdRange(preset), [preset]); const range = useMemo(
() => resolveAdRange(preset, { since: customSince, until: customUntil }),
[preset, customSince, customUntil]
);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -415,30 +420,23 @@ export default function AdTrackerClient({
/> />
{/* Period presets */} {/* Period presets */}
<div> <DateRangePicker
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2"> preset={preset}
Periodo customSince={customSince || range.since}
</p> customUntil={customUntil || range.until}
<div className="flex flex-wrap gap-1.5"> onPreset={(p) => {
{AD_TRACKER_PRESETS.map((p) => ( setPreset(p);
<button if (p !== 'custom') {
key={p.id} const r = resolveAdRange(p);
type="button" setCustomSince(r.since);
onClick={() => setPreset(p.id)} setCustomUntil(r.until);
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${ }
preset === p.id }}
? 'bg-brand-500 text-white' onCustomChange={(since, until) => {
: 'bg-gray-100 text-gray-600 hover:bg-gray-50' setCustomSince(since);
}`} setCustomUntil(until);
> }}
{p.label} />
</button>
))}
<span className="px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-200 text-gray-600">
{range.label}
</span>
</div>
</div>
{/* Tabs */} {/* Tabs */}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">

View File

@@ -0,0 +1,154 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import {
AD_TRACKER_PRESETS,
RangePreset,
isoYesterday,
resolveAdRange,
} from '@/lib/ad-tracker-range';
type Props = {
preset: RangePreset;
customSince: string;
customUntil: string;
onPreset: (preset: RangePreset) => void;
onCustomChange: (since: string, until: string) => void;
/** Compact = fewer labels (for sticky headers). */
compact?: boolean;
};
/**
* Periodo predefinido + rango libre.
* El hasta personalizado no puede pasar de ayer (Hoy solo vía botón «Hoy»).
*/
export default function DateRangePicker({
preset,
customSince,
customUntil,
onPreset,
onCustomChange,
compact = false,
}: Props) {
const yesterday = useMemo(() => isoYesterday(), []);
const range = useMemo(
() =>
resolveAdRange(preset, {
since: customSince,
until: customUntil,
}),
[preset, customSince, customUntil]
);
const [draftSince, setDraftSince] = useState(customSince || range.since);
const [draftUntil, setDraftUntil] = useState(customUntil || range.until);
useEffect(() => {
if (preset === 'custom') {
setDraftSince(customSince || range.since);
setDraftUntil(customUntil || range.until);
} else {
setDraftSince(range.since);
setDraftUntil(range.until);
}
}, [preset, customSince, customUntil, range.since, range.until]);
function applyCustom() {
let s = draftSince || yesterday;
let u = draftUntil || yesterday;
if (u > yesterday) u = yesterday;
if (s > u) s = u;
onCustomChange(s, u);
onPreset('custom');
}
return (
<div className="space-y-2">
{!compact ? (
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Periodo
</p>
) : null}
<div className="flex flex-wrap gap-1.5 items-center">
{AD_TRACKER_PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onPreset(p.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
preset === p.id
? 'bg-brand-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-50'
}`}
>
{p.label}
</button>
))}
<button
type="button"
onClick={() => {
let until = draftUntil || range.until;
let since = draftSince || range.since;
if (until > yesterday) until = yesterday;
if (since > until) since = until;
setDraftSince(since);
setDraftUntil(until);
onCustomChange(since, until);
onPreset('custom');
}}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
preset === 'custom'
? 'bg-brand-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-50'
}`}
>
Personalizado
</button>
<span className="px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-200 text-gray-600">
{range.label}
</span>
</div>
{preset === 'custom' ? (
<div className="flex flex-wrap items-end gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5">
<label className="text-xs text-gray-600">
Desde
<input
type="date"
value={draftSince}
max={draftUntil || yesterday}
onChange={(e) => setDraftSince(e.target.value)}
className="mt-1 block rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm text-gray-900"
/>
</label>
<label className="text-xs text-gray-600">
Hasta
<input
type="date"
value={draftUntil}
max={yesterday}
min={draftSince || undefined}
onChange={(e) => setDraftUntil(e.target.value)}
className="mt-1 block rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm text-gray-900"
/>
</label>
<button
type="button"
onClick={applyCustom}
className="rounded-lg bg-gray-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-gray-800"
>
Aplicar
</button>
<p className="w-full text-[10px] text-gray-500">
El rango personalizado llega como máximo a ayer. Usa «Hoy» para
incluir el día en curso.
</p>
</div>
) : preset !== 'today' ? (
<p className="text-[10px] text-gray-400">
Datos hasta ayer · «Hoy» incluye el día en curso
</p>
) : null}
</div>
);
}

View File

@@ -25,8 +25,8 @@ import {
sumWeekly, sumWeekly,
} from '@/lib/metrics'; } from '@/lib/metrics';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import DateRangePicker from '@/components/DateRangePicker';
import { import {
AD_TRACKER_PRESETS,
RangePreset, RangePreset,
resolveAdRange, resolveAdRange,
} from '@/lib/ad-tracker-range'; } from '@/lib/ad-tracker-range';
@@ -263,6 +263,8 @@ export default function SeguimientoBoard() {
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [preset, setPreset] = useState<RangePreset>('last_30d'); const [preset, setPreset] = useState<RangePreset>('last_30d');
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState('');
const [noteTitle, setNoteTitle] = useState(''); const [noteTitle, setNoteTitle] = useState('');
const [noteBody, setNoteBody] = useState(''); const [noteBody, setNoteBody] = useState('');
const [noteBusy, setNoteBusy] = useState(false); const [noteBusy, setNoteBusy] = useState(false);
@@ -288,7 +290,10 @@ export default function SeguimientoBoard() {
void load(); void load();
}, []); }, []);
const range = useMemo(() => resolveAdRange(preset), [preset]); const range = useMemo(
() => resolveAdRange(preset, { since: customSince, until: customUntil }),
[preset, customSince, customUntil]
);
const rangedWeekly = useMemo(() => { const rangedWeekly = useMemo(() => {
const rows = data?.weekly || []; const rows = data?.weekly || [];
@@ -450,28 +455,28 @@ export default function SeguimientoBoard() {
/> />
{/* Date selector */} {/* Date selector */}
<section className="rounded-2xl border border-white/[0.06] bg-card/80 p-4 space-y-3"> <section className="rounded-2xl border border-gray-200 bg-white p-4 space-y-3">
<div className="flex items-center gap-2 text-xs text-gray-400"> <div className="flex items-center gap-2 text-xs text-gray-500">
<CalendarRange className="w-3.5 h-3.5 text-sky-400" /> <CalendarRange className="w-3.5 h-3.5 text-sky-500" />
Periodo Periodo
<span className="text-gray-500">· {range.label}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{AD_TRACKER_PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setPreset(p.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
preset === p.id
? 'bg-brand-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-50'
}`}
>
{p.label}
</button>
))}
</div> </div>
<DateRangePicker
preset={preset}
customSince={customSince || range.since}
customUntil={customUntil || range.until}
onPreset={(p) => {
setPreset(p);
if (p !== 'custom') {
const r = resolveAdRange(p);
setCustomSince(r.since);
setCustomUntil(r.until);
}
}}
onCustomChange={(since, until) => {
setCustomSince(since);
setCustomUntil(until);
}}
/>
</section> </section>
{/* KPI cards (Métricas-like) */} {/* KPI cards (Métricas-like) */}

View File

@@ -1,4 +1,4 @@
/** Date presets for Ad Tracker (Brava-style). No date-fns dependency. */ /** Date presets for Ad Tracker. Data ends at yesterday unless preset is «Hoy». */
export type RangePreset = export type RangePreset =
| 'today' | 'today'
@@ -34,6 +34,36 @@ function iso(d: Date): string {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
/** UTC calendar day for «today». */
export function utcToday(now = new Date()): Date {
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
);
}
/** UTC calendar day for «yesterday». */
export function utcYesterday(now = new Date()): Date {
const d = utcToday(now);
d.setUTCDate(d.getUTCDate() - 1);
return d;
}
export function isoToday(now = new Date()): string {
return iso(utcToday(now));
}
export function isoYesterday(now = new Date()): string {
return iso(utcYesterday(now));
}
/**
* Max date allowed in the picker / default until.
* Only «Hoy» may include today; everything else ends at yesterday.
*/
export function maxDataDate(preset: RangePreset, now = new Date()): string {
return preset === 'today' ? isoToday(now) : isoYesterday(now);
}
function startOfWeekMon(d: Date): Date { function startOfWeekMon(d: Date): Date {
const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const day = x.getUTCDay(); const day = x.getUTCDay();
@@ -64,45 +94,65 @@ export function formatRangeLabel(since: string, until: string): string {
return `${a[2]} ${MONTHS_ES[a[1] - 1]} ${a[0]} - ${b[2]} ${MONTHS_ES[b[1] - 1]} ${b[0]}`; return `${a[2]} ${MONTHS_ES[a[1] - 1]} ${a[0]} - ${b[2]} ${MONTHS_ES[b[1] - 1]} ${b[0]}`;
} }
function parseIsoDay(s: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const d = new Date(s + 'T00:00:00Z');
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Resolve a date range.
* All presets except «today» end at yesterday (ads warehouse lag).
* Custom ranges are clamped so until ≤ yesterday.
*/
export function resolveAdRange( export function resolveAdRange(
preset: RangePreset, preset: RangePreset,
custom?: { since?: string; until?: string } custom?: { since?: string; until?: string },
now = new Date()
): DateRange { ): DateRange {
const now = new Date(); const today = utcToday(now);
const today = new Date( const yesterday = utcYesterday(now);
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) /** Anchor end for non-today presets. */
); const end = yesterday;
let since = today; let since = new Date(end);
let until = today; let until = new Date(end);
switch (preset) { switch (preset) {
case 'today': case 'today':
since = new Date(today);
until = new Date(today);
break; break;
case 'yesterday': case 'yesterday':
since = new Date(today); since = new Date(yesterday);
since.setUTCDate(since.getUTCDate() - 1); until = new Date(yesterday);
until = new Date(since);
break; break;
case 'last_7d': case 'last_7d':
since = new Date(today); since = new Date(end);
since.setUTCDate(since.getUTCDate() - 6); since.setUTCDate(since.getUTCDate() - 6);
until = new Date(end);
break; break;
case 'last_14d': case 'last_14d':
since = new Date(today); since = new Date(end);
since.setUTCDate(since.getUTCDate() - 13); since.setUTCDate(since.getUTCDate() - 13);
until = new Date(end);
break; break;
case 'last_28d': case 'last_28d':
since = new Date(today); since = new Date(end);
since.setUTCDate(since.getUTCDate() - 27); since.setUTCDate(since.getUTCDate() - 27);
until = new Date(end);
break; break;
case 'last_30d': case 'last_30d':
since = new Date(today); since = new Date(end);
since.setUTCDate(since.getUTCDate() - 29); since.setUTCDate(since.getUTCDate() - 29);
until = new Date(end);
break; break;
case 'this_week': case 'this_week':
since = startOfWeekMon(today); since = startOfWeekMon(today);
until = today; until = new Date(end);
if (since.getTime() > until.getTime()) {
since = new Date(until);
}
break; break;
case 'last_week': { case 'last_week': {
const thisMon = startOfWeekMon(today); const thisMon = startOfWeekMon(today);
@@ -113,7 +163,11 @@ export function resolveAdRange(
} }
case 'this_month': case 'this_month':
since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)); since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1));
until = today; until = new Date(end);
if (since.getTime() > until.getTime()) {
// 1st of month = today → no complete days yet; show yesterday of prev month
since = new Date(until);
}
break; break;
case 'last_month': { case 'last_month': {
const firstThis = new Date( const firstThis = new Date(
@@ -124,11 +178,18 @@ export function resolveAdRange(
since = new Date(Date.UTC(until.getUTCFullYear(), until.getUTCMonth(), 1)); since = new Date(Date.UTC(until.getUTCFullYear(), until.getUTCMonth(), 1));
break; break;
} }
case 'custom': case 'custom': {
since = custom?.since ? new Date(custom.since + 'T00:00:00Z') : since; const maxUntil = yesterday;
until = custom?.until ? new Date(custom.until + 'T00:00:00Z') : until; let s = parseIsoDay(custom?.since || '') || new Date(end);
let u = parseIsoDay(custom?.until || '') || new Date(end);
// Clamp: never past yesterday for custom
if (u.getTime() > maxUntil.getTime()) u = new Date(maxUntil);
if (s.getTime() > u.getTime()) s = new Date(u);
since = s;
until = u;
break; break;
} }
}
const s = iso(since); const s = iso(since);
const u = iso(until); const u = iso(until);

View File

@@ -313,7 +313,8 @@ export function resolveWeeklyRows(
export { isoDate, weekStart }; export { isoDate, weekStart };
/** Same calendar windows as Ad Tracker (inclusive day bounds, UTC). */ /** Same calendar windows as Ad Tracker (inclusive day bounds, UTC).
* Ends at yesterday (warehouse lag) — never includes today. */
export type KpiRangeKey = '7d' | '30d' | 'month' | 'all'; export type KpiRangeKey = '7d' | '30d' | 'month' | 'all';
export function resolveKpiDayRange( export function resolveKpiDayRange(
@@ -323,14 +324,21 @@ export function resolveKpiDayRange(
const today = new Date( const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
); );
const until = isoDate(today); const yesterday = new Date(today);
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const until = isoDate(yesterday);
if (range === 'all') return null; if (range === 'all') return null;
if (range === 'month') { if (range === 'month') {
const since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)); const since = new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)
);
if (since.getTime() > yesterday.getTime()) {
return { since: until, until };
}
return { since: isoDate(since), until }; return { since: isoDate(since), until };
} }
const since = new Date(today); const since = new Date(yesterday);
// Inclusive windows: 7d = today-6..today, 30d = today-29..today // Inclusive windows ending yesterday: 7d = yesterday-6..yesterday
since.setUTCDate(since.getUTCDate() - (range === '7d' ? 6 : 29)); since.setUTCDate(since.getUTCDate() - (range === '7d' ? 6 : 29));
return { since: isoDate(since), until }; return { since: isoDate(since), until };
} }