import React, { useEffect, useState } from 'react'; import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; import { MetricTile, DeltaTone } from '../../components/MetricTile'; import { TileGrid } from '../../components/TileGrid'; import { resolveCurrentRealtimeWindow, resolvePriorPeriod } from '../../data'; import { computeDelta, Delta } from '../../lib/utils'; import { Duration, StatsResult } from '../../utils/admin'; import './Summary.css'; const numberFormat = new Intl.NumberFormat(); const formatNumber = ( value: number ) => { if ( value < 1000 ) { return numberFormat.format( value ); } if ( value < 1_000_000 ) { return `${ ( value / 1000 ).toFixed( value < 10_000 ? 1 : 0 ) }k`; } return `${ ( value / 1_000_000 ).toFixed( 1 ) }M`; }; const formatPercent = ( value: number ) => `${ Math.round( value ) }%`; const formatRatio = ( value: number ) => value.toFixed( 1 ); type PriorSummary = { visitors: number; views: number; bounce: number; } | null; // Fetches the prior-equivalent (or realtime current 15-min) summary so // the strip can render delta arrows alongside each metric. Mirrors the // pattern in HeroChart — see src/components/CLAUDE.md. function useStripDeltas( period: Duration, realtimeTick: number ) { const [ prior, setPrior ] = useState( null ); const [ realtimeCurrent, setRealtimeCurrent ] = useState( null ); const isRealtime = period === 'PT30M'; const lastPriorPeriodRef = React.useRef( null ); useEffect( () => { const win = resolvePriorPeriod( period ); if ( ! win ) { setPrior( null ); lastPriorPeriodRef.current = null; return; } let cancelled = false; // Only blank the prior on a period change, not on tick — keeps delta // arrows visible while the new summary is in-flight. if ( lastPriorPeriodRef.current !== period ) { setPrior( null ); lastPriorPeriodRef.current = period; } apiFetch( { path: addQueryArgs( '/accelerate/v1/stats', { start: win.start.toISOString(), end: win.end.toISOString(), interval: win.interval, tz: win.tz, } ), } ).then( value => { if ( cancelled ) { return; } const s = value?.stats?.summary; setPrior( { visitors: s?.visitors ?? 0, views: s?.views ?? 0, bounce: s?.bounce ?? 0, } ); }, () => { if ( ! cancelled ) setPrior( null ); } ); return () => { cancelled = true; }; }, [ period, realtimeTick ] ); useEffect( () => { if ( ! isRealtime ) { setRealtimeCurrent( null ); return; } const win = resolveCurrentRealtimeWindow(); let cancelled = false; apiFetch( { path: addQueryArgs( '/accelerate/v1/stats', { start: win.start.toISOString(), end: win.end.toISOString(), interval: win.interval, tz: win.tz, } ), } ).then( value => { if ( cancelled ) { return; } const s = value?.stats?.summary; setRealtimeCurrent( { visitors: s?.visitors ?? 0, views: s?.views ?? 0, bounce: s?.bounce ?? 0, } ); }, () => { if ( ! cancelled ) setRealtimeCurrent( null ); } ); return () => { cancelled = true; }; }, [ isRealtime, realtimeTick ] ); return { prior, realtimeCurrent, }; } function toneFor( higherIsBetter: boolean, delta?: Delta ): DeltaTone { if ( ! delta || ! delta.hasData || delta.direction === 0 ) { return 'neutral'; } const good = higherIsBetter ? delta.direction === 1 : delta.direction === -1; return good ? 'positive' : 'negative'; } interface Props { data?: StatsResult; period?: Duration; realtimeTick?: number; } export default function Summary( { data, period = 'P7D', realtimeTick = 0 }: Props ) { const { prior, realtimeCurrent } = useStripDeltas( period, realtimeTick ); if ( ! data ) { return null; } const baseSummary = data.stats.summary; const isRealtime = period === 'PT30M'; const visitors = isRealtime ? ( realtimeCurrent?.visitors ?? 0 ) : ( baseSummary.visitors || 0 ); const views = isRealtime ? ( realtimeCurrent?.views ?? 0 ) : ( baseSummary.views || 0 ); const bounce = isRealtime ? ( realtimeCurrent?.bounce ?? 0 ) : ( baseSummary.bounce || 0 ); const viewsPerUnique = visitors > 0 ? views / visitors : 0; const bouncePct = visitors > 0 ? ( bounce / visitors ) * 100 : 0; const priorVisitors = prior?.visitors ?? 0; const priorViews = prior?.views ?? 0; const priorViewsPerUnique = priorVisitors > 0 ? priorViews / priorVisitors : 0; const priorBouncePct = priorVisitors > 0 ? ( ( prior?.bounce ?? 0 ) / priorVisitors ) * 100 : 0; const viewsDelta = prior ? computeDelta( views, priorViews ) : undefined; const visitorsDelta = prior ? computeDelta( visitors, priorVisitors ) : undefined; const ppvDelta = prior ? computeDelta( Math.round( viewsPerUnique * 10 ), Math.round( priorViewsPerUnique * 10 ) ) : undefined; const bounceDelta = prior ? computeDelta( Math.round( bouncePct ), Math.round( priorBouncePct ) ) : undefined; return ( ); }