diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 8451c85..40c80fb 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { - AD_TRACKER_PRESETS, RangePreset, normalizePlatform, resolveAdRange, @@ -14,6 +13,7 @@ import { CreativeZoomModal, } from '@/components/AdCreativesGallery'; import { PageHeader } from '@/components/ui'; +import DateRangePicker from '@/components/DateRangePicker'; type PlatformKpis = { spend: number; @@ -318,6 +318,8 @@ export default function AdTrackerClient({ rows: AdDailyRow[]; }) { const [preset, setPreset] = useState('last_7d'); + const [customSince, setCustomSince] = useState(''); + const [customUntil, setCustomUntil] = useState(''); const [tab, setTab] = useState('resumen'); const [chartMode, setChartMode] = useState('combined'); const [creatives, setCreatives] = useState([]); @@ -325,7 +327,10 @@ export default function AdTrackerClient({ const [creativesWarning, setCreativesWarning] = useState(null); const [zoomAd, setZoomAd] = useState(null); - const range = useMemo(() => resolveAdRange(preset), [preset]); + const range = useMemo( + () => resolveAdRange(preset, { since: customSince, until: customUntil }), + [preset, customSince, customUntil] + ); useEffect(() => { let cancelled = false; @@ -415,30 +420,23 @@ export default function AdTrackerClient({ /> {/* Period presets */} -
-

- Periodo -

-
- {AD_TRACKER_PRESETS.map((p) => ( - - ))} - - {range.label} - -
-
+ { + setPreset(p); + if (p !== 'custom') { + const r = resolveAdRange(p); + setCustomSince(r.since); + setCustomUntil(r.until); + } + }} + onCustomChange={(since, until) => { + setCustomSince(since); + setCustomUntil(until); + }} + /> {/* Tabs */}
diff --git a/src/components/DateRangePicker.tsx b/src/components/DateRangePicker.tsx new file mode 100644 index 0000000..8bcfd12 --- /dev/null +++ b/src/components/DateRangePicker.tsx @@ -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 ( +
+ {!compact ? ( +

+ Periodo +

+ ) : null} +
+ {AD_TRACKER_PRESETS.map((p) => ( + + ))} + + + {range.label} + +
+ + {preset === 'custom' ? ( +
+ + + +

+ El rango personalizado llega como máximo a ayer. Usa «Hoy» para + incluir el día en curso. +

+
+ ) : preset !== 'today' ? ( +

+ Datos hasta ayer · «Hoy» incluye el día en curso +

+ ) : null} +
+ ); +} diff --git a/src/components/SeguimientoBoard.tsx b/src/components/SeguimientoBoard.tsx index 37cd737..d532739 100644 --- a/src/components/SeguimientoBoard.tsx +++ b/src/components/SeguimientoBoard.tsx @@ -25,8 +25,8 @@ import { sumWeekly, } from '@/lib/metrics'; import { PageHeader } from '@/components/ui'; +import DateRangePicker from '@/components/DateRangePicker'; import { - AD_TRACKER_PRESETS, RangePreset, resolveAdRange, } from '@/lib/ad-tracker-range'; @@ -263,6 +263,8 @@ export default function SeguimientoBoard() { const [openId, setOpenId] = useState(null); const [query, setQuery] = useState(''); const [preset, setPreset] = useState('last_30d'); + const [customSince, setCustomSince] = useState(''); + const [customUntil, setCustomUntil] = useState(''); const [noteTitle, setNoteTitle] = useState(''); const [noteBody, setNoteBody] = useState(''); const [noteBusy, setNoteBusy] = useState(false); @@ -288,7 +290,10 @@ export default function SeguimientoBoard() { void load(); }, []); - const range = useMemo(() => resolveAdRange(preset), [preset]); + const range = useMemo( + () => resolveAdRange(preset, { since: customSince, until: customUntil }), + [preset, customSince, customUntil] + ); const rangedWeekly = useMemo(() => { const rows = data?.weekly || []; @@ -450,28 +455,28 @@ export default function SeguimientoBoard() { /> {/* Date selector */} -
-
- +
+
+ Periodo - · {range.label} -
-
- {AD_TRACKER_PRESETS.map((p) => ( - - ))}
+ { + setPreset(p); + if (p !== 'custom') { + const r = resolveAdRange(p); + setCustomSince(r.since); + setCustomUntil(r.until); + } + }} + onCustomChange={(since, until) => { + setCustomSince(since); + setCustomUntil(until); + }} + />
{/* KPI cards (Métricas-like) */} diff --git a/src/lib/ad-tracker-range.ts b/src/lib/ad-tracker-range.ts index 090719b..dd9fa6d 100644 --- a/src/lib/ad-tracker-range.ts +++ b/src/lib/ad-tracker-range.ts @@ -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 = | 'today' @@ -34,6 +34,36 @@ function iso(d: Date): string { 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 { const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); 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]}`; } +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( preset: RangePreset, - custom?: { since?: string; until?: string } + custom?: { since?: string; until?: string }, + now = new Date() ): DateRange { - const now = new Date(); - const today = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - ); + const today = utcToday(now); + const yesterday = utcYesterday(now); + /** Anchor end for non-today presets. */ + const end = yesterday; - let since = today; - let until = today; + let since = new Date(end); + let until = new Date(end); switch (preset) { case 'today': + since = new Date(today); + until = new Date(today); break; case 'yesterday': - since = new Date(today); - since.setUTCDate(since.getUTCDate() - 1); - until = new Date(since); + since = new Date(yesterday); + until = new Date(yesterday); break; case 'last_7d': - since = new Date(today); + since = new Date(end); since.setUTCDate(since.getUTCDate() - 6); + until = new Date(end); break; case 'last_14d': - since = new Date(today); + since = new Date(end); since.setUTCDate(since.getUTCDate() - 13); + until = new Date(end); break; case 'last_28d': - since = new Date(today); + since = new Date(end); since.setUTCDate(since.getUTCDate() - 27); + until = new Date(end); break; case 'last_30d': - since = new Date(today); + since = new Date(end); since.setUTCDate(since.getUTCDate() - 29); + until = new Date(end); break; case 'this_week': since = startOfWeekMon(today); - until = today; + until = new Date(end); + if (since.getTime() > until.getTime()) { + since = new Date(until); + } break; case 'last_week': { const thisMon = startOfWeekMon(today); @@ -113,7 +163,11 @@ export function resolveAdRange( } case 'this_month': 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; case 'last_month': { const firstThis = new Date( @@ -124,10 +178,17 @@ export function resolveAdRange( since = new Date(Date.UTC(until.getUTCFullYear(), until.getUTCMonth(), 1)); break; } - case 'custom': - since = custom?.since ? new Date(custom.since + 'T00:00:00Z') : since; - until = custom?.until ? new Date(custom.until + 'T00:00:00Z') : until; + case 'custom': { + const maxUntil = yesterday; + 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; + } } const s = iso(since); diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 19df8b3..2253595 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -313,7 +313,8 @@ export function resolveWeeklyRows( 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 function resolveKpiDayRange( @@ -323,14 +324,21 @@ export function resolveKpiDayRange( const today = new Date( 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 === '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 }; } - const since = new Date(today); - // Inclusive windows: 7d = today-6..today, 30d = today-29..today + const since = new Date(yesterday); + // Inclusive windows ending yesterday: 7d = yesterday-6..yesterday since.setUTCDate(since.getUTCDate() - (range === '7d' ? 6 : 29)); return { since: isoDate(since), until }; }