diff --git a/src/App.tsx b/src/App.tsx index 8fff58c..0e18a0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,9 +15,10 @@ import { useAnimation } from './hooks/useAnimation'; import { useMediaQuery } from './hooks/useMediaQuery'; import { InfoPanel } from './components/InfoPanel'; import { initialUrlState, useUrlSync } from './hooks/useUrlState'; +import { type Period, periodKey, isPastRange } from './types/period'; export default function App() { - const [periodDays, setPeriodDays] = useState(initialUrlState.period); + const [period, setPeriod] = useState(initialUrlState.period); const [transactions, setTransactions] = useState([]); const [arcs, setArcs] = useState([]); const [stats, setStats] = useState(null); @@ -52,14 +53,14 @@ export default function App() { } }; - const animation = useAnimation(transactions, arcs, periodDays, allTimestamps); + const animation = useAnimation(transactions, arcs, period, allTimestamps); // Synchronise l'état dans l'URL (deep link / partage) - useUrlSync(periodDays, viewMode, focusCity); + useUrlSync(period, viewMode, focusCity); - const handlePeriodChange = (days: number) => { + const handlePeriodChange = (newPeriod: Period) => { animation.deactivate(); - setPeriodDays(days); + setPeriod(newPeriod); }; const handleViewModeChange = (mode: 'heatmap' | 'flow') => { @@ -98,7 +99,7 @@ export default function App() { const load = (showLoading: boolean) => { if (showLoading) setLoading(true); else setRefreshing(true); - fetchData(periodDays) + fetchData(period) .then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => { if (!cancelled) { setTransactions(transactions); @@ -117,10 +118,11 @@ export default function App() { }; load(true); - const interval = setInterval(() => load(false), 120_000); + const interval = isPastRange(period) ? null : setInterval(() => load(false), 120_000); - return () => { cancelled = true; clearInterval(interval); }; - }, [periodDays, endpointVersion]); + return () => { cancelled = true; if (interval) clearInterval(interval); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [periodKey(period), endpointVersion]); // Stats heatmap sur la fenêtre courante en mode animation const visibleStats: PeriodStats | null = animation.active @@ -143,7 +145,7 @@ export default function App() { const statsPanelProps = { stats: visibleStats, loading, - periodDays, + period, source, currentUD, animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined, @@ -223,7 +225,7 @@ export default function App() { {/* Period selector — floating over map */}
animation.active ? animation.deactivate() : animation.activate()} diff --git a/src/components/PeriodSelector.tsx b/src/components/PeriodSelector.tsx index 3fb7953..6fc0258 100644 --- a/src/components/PeriodSelector.tsx +++ b/src/components/PeriodSelector.tsx @@ -1,8 +1,9 @@ import { useState, useRef, useEffect } from 'react'; +import { type Period } from '../types/period'; interface PeriodSelectorProps { - value: number; - onChange: (days: number) => void; + period: Period; + onChange: (period: Period) => void; animationActive: boolean; onAnimate: () => void; viewMode: 'heatmap' | 'flow'; @@ -10,50 +11,93 @@ interface PeriodSelectorProps { geoPercent?: number | null; } -const PERIODS = [ +const PRESETS = [ { label: '24h', days: 1 }, { label: '7 jours', days: 7 }, { label: '30 jours', days: 30 }, ]; - const PRESET_DAYS = new Set([1, 7, 30]); -export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) { - const [customOpen, setCustomOpen] = useState(false); - const [inputVal, setInputVal] = useState(''); - const inputRef = useRef(null); +function toDateInputValue(d: Date): string { + return d.toISOString().split('T')[0]; +} + +export function PeriodSelector({ period, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) { + const [customOpen, setCustomOpen] = useState(false); + const [rangeOpen, setRangeOpen] = useState(false); + const [inputVal, setInputVal] = useState(''); + const [fromInput, setFromInput] = useState(''); + const [toInput, setToInput] = useState(''); + const inputRef = useRef(null); + const fromRef = useRef(null); + + const isSliding = period.type === 'sliding'; + const isPreset = isSliding && PRESET_DAYS.has(period.days); + const isCustomDay = isSliding && !PRESET_DAYS.has(period.days); + const isRange = period.type === 'range'; + + const todayStr = toDateInputValue(new Date()); - // Ouvre le champ custom avec la valeur courante pré-remplie const openCustom = () => { - setInputVal(PRESET_DAYS.has(value) ? '' : String(value)); + setRangeOpen(false); + setInputVal(isCustomDay ? String(period.days) : ''); setCustomOpen(true); }; - useEffect(() => { - if (customOpen) inputRef.current?.focus(); - }, [customOpen]); + const openRange = () => { + setCustomOpen(false); + if (isRange) { + setFromInput(toDateInputValue(period.from)); + setToInput(toDateInputValue(period.to)); + } else { + const to = new Date(); + const from = new Date(to.getTime() - 30 * 86_400_000); + setFromInput(toDateInputValue(from)); + setToInput(toDateInputValue(to)); + } + setRangeOpen(true); + }; - const commit = () => { + useEffect(() => { if (customOpen) inputRef.current?.focus(); }, [customOpen]); + useEffect(() => { if (rangeOpen) fromRef.current?.focus(); }, [rangeOpen]); + + const commitDays = () => { const n = parseInt(inputVal, 10); - if (n >= 1 && n <= 365) onChange(n); + if (n >= 1) onChange({ type: 'sliding', days: n }); setCustomOpen(false); }; - const isCustomActive = !PRESET_DAYS.has(value); + const commitRange = () => { + if (!fromInput || !toInput) return; + const from = new Date(fromInput); + const to = new Date(toInput); + to.setHours(23, 59, 59, 999); + if (isNaN(from.getTime()) || isNaN(to.getTime()) || from >= to) return; + onChange({ type: 'range', from, to }); + setRangeOpen(false); + }; + + const btnClass = (active: boolean) => ` + px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer + ${active + ? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]' + : 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]' + } + `; + + const rangeLabel = isRange + ? `${period.from.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} → ${period.to.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}` + : 'Plage'; return (
- {PERIODS.map(({ label, days }) => ( + + {/* Préréglages */} + {PRESETS.map(({ label, days }) => ( @@ -61,53 +105,68 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
- {/* Bouton Personnaliser + champ inline */} + {/* Nombre de jours personnalisé */} {customOpen ? (
setInputVal(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commit(); - if (e.key === 'Escape') setCustomOpen(false); - }} - onBlur={commit} + onKeyDown={(e) => { if (e.key === 'Enter') commitDays(); if (e.key === 'Escape') setCustomOpen(false); }} + onBlur={commitDays} placeholder="jours" className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" /> j
) : ( - + )} + + {/* Plage de dates */} + {rangeOpen ? ( +
+ setFromInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') setRangeOpen(false); }} + className="w-32 px-2 py-1 text-xs bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] focus:outline-none" + /> + + setToInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') commitRange(); if (e.key === 'Escape') setRangeOpen(false); }} + className="w-32 px-2 py-1 text-xs bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] focus:outline-none" + /> + + +
+ ) : ( + )}
- @@ -115,22 +174,16 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi + {geoPercent != null && ( {geoPercent}% Tx géoloc. )} -
); } diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx index fbfd2f0..171b970 100644 --- a/src/components/Sparkline.tsx +++ b/src/components/Sparkline.tsx @@ -2,30 +2,34 @@ import { useMemo } from 'react'; interface SparklineProps { timestamps: number[]; - periodDays: number; + fromMs: number; + toMs: number; } /** - * Mini bar-chart SVG affichant l'activité journalière sur la période. + * Mini bar-chart SVG affichant l'activité sur la période. * Utilise les timestamps déjà en mémoire — aucune requête supplémentaire. */ -export function Sparkline({ timestamps, periodDays }: SparklineProps) { +export function Sparkline({ timestamps, fromMs, toMs }: SparklineProps) { const buckets = useMemo(() => { if (timestamps.length === 0) return []; - const n = periodDays === 1 ? 24 : Math.min(periodDays, 30); - const now = Date.now(); - const start = now - periodDays * 864e5; - const step = (periodDays * 864e5) / n; + const duration = toMs - fromMs; + const days = duration / 864e5; + const n = days <= 1 ? 24 : Math.min(Math.ceil(days), 30); + const step = duration / n; const counts = new Array(n).fill(0); for (const ts of timestamps) { - const i = Math.floor((ts - start) / step); + const i = Math.floor((ts - fromMs) / step); if (i >= 0 && i < n) counts[i]++; } return counts; - }, [timestamps, periodDays]); + }, [timestamps, fromMs, toMs]); if (buckets.length === 0) return null; + const duration = toMs - fromMs; + const days = duration / 864e5; + const n = buckets.length; const max = Math.max(...buckets, 1); const W = 100; @@ -33,6 +37,11 @@ export function Sparkline({ timestamps, periodDays }: SparklineProps) { const barW = W / n; const gap = barW * 0.18; + const fmtLabel = (ms: number) => { + if (days <= 1) return new Date(ms).getHours() + 'h'; + return new Date(ms).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); + }; + return (
- {periodDays === 1 ? '0h' : 'J-' + periodDays} - {periodDays === 1 ? 'maintenant' : 'aujourd\'hui'} + {fmtLabel(fromMs)} + {fmtLabel(toMs)}
); diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index 35e823b..9054772 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -6,11 +6,12 @@ import { ServiceStatusDots } from './ServiceStatusDots'; import { useServiceStatus } from '../hooks/useServiceStatus'; import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser'; import type { Transaction } from '../data/mockData'; +import { type Period, periodToDates, periodToDays } from '../types/period'; interface StatsPanelProps { stats: PeriodStats | null; loading: boolean; - periodDays: number; + period: Period; source: 'live' | 'mock'; className?: string; currentUD: number; @@ -68,10 +69,14 @@ function CityRow({ city, volume, count, countryCode, accent }: { ); } -export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) { +export function StatsPanel({ stats, loading, period, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) { const { subsquid, cesium, recheck } = useServiceStatus(); const [openCategory, setOpenCategory] = useState(null); - const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; + const { from, to } = periodToDates(period); + const days = periodToDays(period); + const periodLabel = period.type === 'range' + ? `${from.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })} → ${to.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}` + : days === 1 ? '24 dernières heures' : `${days} derniers jours`; const prevStats = useRef(null); // Calcule le delta d'une valeur par rapport au refresh précédent @@ -136,7 +141,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim }

{!animationLabel && allTimestamps.length > 0 && ( - + )}
diff --git a/src/data/mockData.ts b/src/data/mockData.ts index c363782..fa0dffb 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -94,12 +94,11 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] { const POOL_GENERATED_AT = Date.now(); const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000); -export function getTransactionsForPeriod(periodDays: number): Transaction[] { +export function getTransactionsForPeriod(from: Date, to: Date): Transaction[] { const drift = Date.now() - POOL_GENERATED_AT; - const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000; return TRANSACTION_POOL .map((tx) => ({ ...tx, timestamp: tx.timestamp + drift })) - .filter((tx) => tx.timestamp >= cutoff); + .filter((tx) => tx.timestamp >= from.getTime() && tx.timestamp <= to.getTime()); } export function computeStats(transactions: Transaction[]) { diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts index 8a57710..fc6ac08 100644 --- a/src/hooks/useAnimation.ts +++ b/src/hooks/useAnimation.ts @@ -1,6 +1,7 @@ import { useState, useMemo, useEffect } from 'react'; import type { Transaction } from '../data/mockData'; import type { TransactionArc } from '../data/arcData'; +import { type Period, periodToDates, periodKey } from '../types/period'; export interface TimeFrame { label: string; @@ -8,47 +9,57 @@ export interface TimeFrame { to: number; // Unix ms } -function buildFrames(periodDays: number): TimeFrame[] { - const now = Date.now(); - const start = now - periodDays * 24 * 60 * 60 * 1000; +function buildFrames(fromMs: number, toMs: number): TimeFrame[] { + const duration = toMs - fromMs; + const days = duration / 86_400_000; const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) => new Date(ms).toLocaleDateString('fr-FR', opts); - if (periodDays === 1) { - return Array.from({ length: 24 }, (_, i) => { - const from = start + i * 3_600_000; - const to = from + 3_600_000; + // ≤ 2 jours : frames horaires + if (days <= 2) { + const frames: TimeFrame[] = []; + let cursor = fromMs; + while (cursor < toMs) { + const from = cursor; + const to = Math.min(cursor + 3_600_000, toMs); const h = new Date(from).getHours(); - return { - label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h – ${h + 1}h`, + frames.push({ + label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h`, from, to, - }; - }); + }); + cursor = to; + } + return frames; } - if (periodDays === 7) { - return Array.from({ length: 7 }, (_, i) => { - const from = start + i * 86_400_000; - const to = from + 86_400_000; - return { + // ≤ 14 jours : frames journalières + if (days <= 14) { + const frames: TimeFrame[] = []; + let cursor = fromMs; + while (cursor < toMs) { + const from = cursor; + const to = Math.min(cursor + 86_400_000, toMs); + frames.push({ label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }), from, to, - }; - }); + }); + cursor = to; + } + return frames; } - // 30 days → half-week frames (3.5 days ≈ 9–10 frames) - const HALF_WEEK = 3.5 * 86_400_000; + // > 14 jours : frames hebdomadaires + const WEEK = 7 * 86_400_000; const frames: TimeFrame[] = []; - let cursor = start; - while (cursor < now) { + let cursor = fromMs; + while (cursor < toMs) { const from = cursor; - const to = Math.min(cursor + HALF_WEEK, now); + const to = Math.min(cursor + WEEK, toMs); frames.push({ - label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`, + label: `${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`, from, to, }); @@ -57,32 +68,33 @@ function buildFrames(periodDays: number): TimeFrame[] { return frames; } -export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], periodDays: number, allTimestamps: number[] = []) { +export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], period: Period, allTimestamps: number[] = []) { const [active, setActive] = useState(false); const [currentIndex, setCurrentIndex] = useState(0); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState<1 | 2 | 4>(2); - const frames = useMemo(() => buildFrames(periodDays), [periodDays]); + const key = periodKey(period); + + const frames = useMemo(() => { + const { from, to } = periodToDates(period); + return buildFrames(from.getTime(), to.getTime()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); - // Reset cursor when period or activation changes. - // Stop playback only on deactivation — not on activation, so activate() can - // start playing immediately without being overridden by this effect. useEffect(() => { setCurrentIndex(0); if (!active) setPlaying(false); - }, [periodDays, active]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, active]); - // Auto-advance: one step every (2000 / speed) ms + // Auto-advance: one step every (1500 / speed) ms useEffect(() => { if (!playing || !active) return; - const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms + const delay = 1500 / speed; const t = setTimeout(() => { setCurrentIndex((i) => { - if (i >= frames.length - 1) { - setPlaying(false); - return i; - } + if (i >= frames.length - 1) { setPlaying(false); return i; } return i + 1; }); }, delay); @@ -103,7 +115,6 @@ export function useAnimation(transactions: Transaction[], arcs: TransactionArc[] return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to); }, [active, arcs, frames, currentIndex]); - // Nombre total de transfers (géo + non-géo) dans la frame courante const frameTotalCount = useMemo(() => { if (!active || frames.length === 0 || allTimestamps.length === 0) return null; const frame = frames[currentIndex]; diff --git a/src/hooks/useUrlState.ts b/src/hooks/useUrlState.ts index 061dd92..3612f7e 100644 --- a/src/hooks/useUrlState.ts +++ b/src/hooks/useUrlState.ts @@ -5,17 +5,34 @@ * Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour. * * Paramètres supportés : - * ?period=7&view=flow&city=Paris + * ?period=7&view=flow&city=Paris (mode glissant) + * ?from=2026-01-01&to=2026-01-31&view=flow (mode plage) */ import { useEffect } from 'react'; +import { type Period, periodKey } from '../types/period'; -function parseInitialState(): { period: number; view: 'heatmap' | 'flow'; city: string | null } { +function parseInitialState(): { period: Period; view: 'heatmap' | 'flow'; city: string | null } { const p = new URLSearchParams(window.location.search); - const period = parseInt(p.get('period') ?? '', 10); + const view = p.get('view') === 'flow' ? 'flow' : 'heatmap'; + const city = p.get('city') ?? null; + + // Mode plage : ?from=YYYY-MM-DD&to=YYYY-MM-DD + const fromStr = p.get('from'); + const toStr = p.get('to'); + if (fromStr && toStr) { + const from = new Date(fromStr); + const to = new Date(toStr); + if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from < to) { + return { period: { type: 'range', from, to }, view, city }; + } + } + + // Mode glissant : ?period=30 + const days = parseInt(p.get('period') ?? '', 10); return { - period: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7, - view: p.get('view') === 'flow' ? 'flow' : 'heatmap', - city: p.get('city') ?? null, + period: { type: 'sliding', days: Number.isFinite(days) && days >= 1 ? days : 7 }, + view, + city, }; } @@ -24,16 +41,23 @@ export const initialUrlState = parseInitialState(); /** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */ export function useUrlSync( - periodDays: number, + period: Period, viewMode: 'heatmap' | 'flow', focusCity: string | null, ) { + const key = periodKey(period); useEffect(() => { const p = new URLSearchParams(); - if (periodDays !== 7) p.set('period', String(periodDays)); + if (period.type === 'range') { + p.set('from', period.from.toISOString().split('T')[0]); + p.set('to', period.to.toISOString().split('T')[0]); + } else if (period.days !== 7) { + p.set('period', String(period.days)); + } if (viewMode !== 'heatmap') p.set('view', viewMode); if (focusCity) p.set('city', focusCity); const qs = p.toString(); history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); - }, [periodDays, viewMode, focusCity]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, viewMode, focusCity]); } diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 5a7fb5d..c31d6ee 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -13,6 +13,7 @@ */ import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter'; +import { type Period, periodToDates, periodToDays } from '../types/period'; import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter'; import { parseComment } from '../data/commentParser'; import { @@ -47,16 +48,17 @@ async function getIdentityKeyMap(): Promise> { return map; } -async function fetchLiveTransactions(periodDays: number): Promise<{ +async function fetchLiveTransactions(period: Period): Promise<{ geolocated: Transaction[]; arcs: TransactionArc[]; totalCount: number; totalVolume: number; allTimestamps: number[]; }> { - // ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000 - const limit = Math.max(2000, Math.ceil(periodDays * 600)); - const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit); + const { from, to } = periodToDates(period); + const days = periodToDays(period); + const limit = Math.max(2000, Math.ceil(days * 600)); + const { transfers: rawTransfers, totalCount } = await fetchTransfers(from, to, limit); if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] }; const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); @@ -210,10 +212,11 @@ export interface DataResult { allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo) } -export async function fetchData(periodDays: number): Promise { +export async function fetchData(period: Period): Promise { if (!USE_LIVE_API) { await new Promise((r) => setTimeout(r, 80)); - const transactions = getTransactionsForPeriod(periodDays); + const { from, to } = periodToDates(period); + const transactions = getTransactionsForPeriod(from, to); const base = computeStats(transactions); const arcs = buildMockArcs(transactions); return { @@ -227,7 +230,7 @@ export async function fetchData(periodDays: number): Promise { } const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([ - fetchLiveTransactions(periodDays), + fetchLiveTransactions(period), getCurrentUD(), ]); const base = computeStats(geolocated); diff --git a/src/services/adapters/SubsquidAdapter.ts b/src/services/adapters/SubsquidAdapter.ts index af70ca8..78f014c 100644 --- a/src/services/adapters/SubsquidAdapter.ts +++ b/src/services/adapters/SubsquidAdapter.ts @@ -60,11 +60,11 @@ export interface RawTransfer { // Query // --------------------------------------------------------------------------- const TRANSFERS_QUERY = ` - query GetTransfers($since: Datetime!, $limit: Int!) { + query GetTransfers($since: Datetime!, $until: Datetime!, $limit: Int!) { transfers( orderBy: TIMESTAMP_DESC first: $limit - filter: { timestamp: { greaterThanOrEqualTo: $since } } + filter: { timestamp: { greaterThanOrEqualTo: $since, lessThanOrEqualTo: $until } } ) { totalCount nodes { @@ -221,19 +221,19 @@ export interface FetchTransfersResult { } export async function fetchTransfers( - periodDays: number, + from: Date, + to: Date, limit = 2000 ): Promise { - const since = new Date( - Date.now() - periodDays * 24 * 60 * 60 * 1000 - ).toISOString(); + const since = from.toISOString(); + const until = to.toISOString(); const response = await fetch(getSubsquidUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: TRANSFERS_QUERY, - variables: { since, limit }, + variables: { since, until, limit }, }), }); diff --git a/src/types/period.ts b/src/types/period.ts new file mode 100644 index 0000000..ea00c15 --- /dev/null +++ b/src/types/period.ts @@ -0,0 +1,28 @@ +export type Period = + | { type: 'sliding'; days: number } + | { type: 'range'; from: Date; to: Date } + +export function periodToDates(period: Period): { from: Date; to: Date } { + if (period.type === 'range') return { from: period.from, to: period.to }; + const to = new Date(); + const from = new Date(to.getTime() - period.days * 86_400_000); + return { from, to }; +} + +export function periodToDays(period: Period): number { + const { from, to } = periodToDates(period); + return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000)); +} + +/** Clé stable pour deps React — ne change pas entre deux renders pour le mode glissant */ +export function periodKey(period: Period): string { + return period.type === 'sliding' + ? `s-${period.days}` + : `r-${period.from.toISOString().split('T')[0]}-${period.to.toISOString().split('T')[0]}`; +} + +/** Vrai si la plage est entièrement dans le passé (auto-refresh inutile) */ +export function isPastRange(period: Period): boolean { + if (period.type === 'sliding') return false; + return period.to.getTime() < Date.now() - 5 * 60 * 1000; +}