import { AxisLeft, AxisBottom } from '@visx/axis'; import { curveMonotoneX } from '@visx/curve'; import { localPoint } from '@visx/event'; import { LinearGradient } from '@visx/gradient'; import { GridRows, GridColumns } from '@visx/grid'; import { Group } from '@visx/group'; import { MarkerCircle } from '@visx/marker'; import { scaleLinear, scaleTime } from '@visx/scale'; import { LinePath, AreaClosed, Bar } from '@visx/shape'; import { Text } from '@visx/text'; import { TooltipWithBounds, useTooltip } from '@visx/tooltip'; import { extent, max, bisector } from 'd3-array'; import moment from 'moment'; import { motion, useReducedMotion } from 'motion/react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import apiFetch from '@wordpress/api-fetch'; import { useSelect } from '@wordpress/data'; import { __, _n } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; import { resolveCurrentRealtimeWindow, resolvePriorPeriod, REALTIME_VISIBLE_MINUTES } from '../../data'; import { usePersistentState } from '../../data/hooks'; import { periods } from '../../data/periods'; import { compactMetric, Duration, padLeft, PeriodObject, StatsResult, trackEvent } from '../../utils/admin'; import { formatEngaged } from './BreakdownPanel/formatEngaged'; import { EmptyState } from '@/components/EmptyState'; import { MetricTile, DeltaTone } from '@/components/MetricTile'; import { TileGrid } from '@/components/TileGrid'; import { AnimatedTabs } from '@/components/ui/animated-tabs'; import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { computeDelta, Delta } from '@/lib/utils'; type Props = { period: Duration; // Bumped by the parent on each realtime refresh; included in the // stats request so the resolver invalidates its cache. realtimeTick?: number; }; type Datum = { time: Date; uniques: number; views: number; }; const getX = ( d: Datum ) => d.time; const getViews = ( d: Datum ) => d?.views || 0; const getUniques = ( d: Datum ) => d?.uniques || 0; const bisectDate = bisector( ( d: Datum ) => d.time ).left; // Formatters used in the metric strip. Module-level so they don't // reallocate per render. const numberFormat = new Intl.NumberFormat(); const formatCount = ( 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 formatRatio = ( v: number ) => v.toFixed( 1 ); const formatPercent = ( v: number ) => `${ Math.round( v ) }%`; // Direction-of-good map: higher = better for visitors/views/ratio, // higher = worse for bounce. Tone is derived from the delta direction // crossed with this — see toneFor() below. 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'; } const getTooltip = ( data : Datum, interval : string, period: PeriodObject ) => { const date = getX( data ); let dateString = moment( date ).format( 'MMM Do' ); // PT1H → 1, PT6H → 6, etc. const isoHours = interval.match( /^PT(\d+)H$/ ); let intervalHours = Number( isoHours ? isoHours[1] : 0 ); if ( intervalHours === 1 ) { dateString = `${ padLeft( date.getHours() ) }:00`; } else if ( intervalHours ) { const offset = date.getHours() + intervalHours; const wrappedOffset = offset < 24 ? offset : 0; dateString = `${ padLeft( date.getHours() ) }:00 — ${ padLeft( wrappedOffset ) }:00`; } else if ( period.value === 'PT30M' ) { dateString = `${ padLeft( date.getHours() ) }:${ padLeft( date.getMinutes() ) }${ date.getHours() >= 12 ? 'PM' : 'AM' }`; } return ( { compactMetric( data.views ) } { _n( 'view', 'views', data.views, 'altis' ) }
); }; type PriorSummary = { visitors: number; views: number; bounce: number; } | null; // Realtime current-window summary fetcher. Fetches the current // 30-min window on the same minute-snapped boundaries as // `resolvePriorPeriod`, so the strip compares apples-to-apples // against the prior 30min. // Returns null outside of realtime — callers fall back to the chart's // existing 30min summary. function useRealtimeCurrentSummary( isRealtime: boolean, tick: number ): PriorSummary { const [ summary, setSummary ] = useState( null ); const lastRef = React.useRef( null ); useEffect( () => { if ( ! isRealtime ) { setSummary( null ); lastRef.current = 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, } ), } ).then( value => { if ( cancelled ) { return; } const s = value?.stats?.summary; const next = { visitors: s?.visitors ?? 0, views: s?.views ?? 0, bounce: s?.bounce ?? 0, }; lastRef.current = next; setSummary( next ); }, () => { // On error keep the last good value so the strip doesn't flash to 0. if ( ! cancelled ) { setSummary( lastRef.current ); } } ); return () => { cancelled = true; }; }, [ isRealtime, tick ] ); return summary; } // Prior-period summary fetcher. Returns `null` when the period has no // meaningful prior (PT30M) or while loading/erroring — consumers render // neutral em-dashes in those cases. Keyed on period.value so a period // change discards the in-flight response. Refetches when `tick` // increments (the parent's realtime-tick prop). function usePriorSummary( periodValue: Duration, tick: number ): PriorSummary { const [ summary, setSummary ] = useState( null ); const lastPeriodRef = React.useRef( null ); useEffect( () => { const prior = resolvePriorPeriod( periodValue ); if ( ! prior ) { setSummary( null ); lastPeriodRef.current = null; return; } let cancelled = false; // Only reset to null on period change, not on realtime tick — keeps // delta arrows visible while the new summary is fetching. if ( lastPeriodRef.current !== periodValue ) { setSummary( null ); lastPeriodRef.current = periodValue; } apiFetch( { path: addQueryArgs( '/accelerate/v1/stats', { start: prior.start.toISOString(), end: prior.end.toISOString(), interval: prior.interval, } ), } ).then( value => { if ( cancelled ) { return; } const s = value?.stats?.summary; setSummary( { visitors: s?.visitors ?? 0, views: s?.views ?? 0, bounce: s?.bounce ?? 0, } ); }, () => { // Swallow: prior is best-effort; tile falls back to neutral. if ( ! cancelled ) { setSummary( null ); } } ); return () => { cancelled = true; }; }, [ periodValue, tick ] ); return summary; } export default function HeroChart( props: Props ) { const { period: periodKey, realtimeTick = 0 } = props; const period = periods.find( p => p.value === periodKey ) || periods[0]; // Get stats data. const [ outerWidth, setOuterWidth ] = useState( 0 ); const [ resolution, setResolution ] = usePersistentState( 'hero-chart-resolution', period.intervals[ period.defaultInterval || 0 ].interval ); const isRealTime = period.value === 'PT30M'; // In realtime mode the chart's right edge tracks wall-clock "now" via // a rAF loop. This is what makes the line continuously drift left: // each frame, the scale's max time advances by ~16ms, so every data // point (whose time is fixed) appears slightly further left than the // previous frame. ~10fps (every 100ms) is plenty smooth visually and // keeps the cost down. Non-realtime modes don't tick — they stay at // initial-mount time and only update on data ticks. const [ displayNow, setDisplayNow ] = useState( () => Date.now() ); useEffect( () => { if ( ! isRealTime ) { return; } let rafId = 0; let last = 0; const loop = ( ts: number ) => { // Throttle to ~10fps — enough for smooth perception, far less // CPU than running the visx render at 60fps for 30 minutes. if ( ts - last >= 100 ) { last = ts; setDisplayNow( Date.now() ); } rafId = requestAnimationFrame( loop ); }; rafId = requestAnimationFrame( loop ); return () => cancelAnimationFrame( rafId ); }, [ isRealTime ] ); const data = useSelect( select => { let chosen_resolution = resolution; // The `resolution` may be persistend state that is not a valid resolution for the current period. if ( resolution && period.intervals.map( i => i.interval ).indexOf( resolution ) === -1 ) { chosen_resolution = period.intervals[ period.defaultInterval || 0 ].interval; } return select( 'accelerate' ).getStats( { period: period.value || 'P7D', interval: chosen_resolution || period.intervals[ period.defaultInterval || 0 ].interval || 'P1D', // Only include `time` when non-zero so non-realtime periods share // the same cache key as List.tsx and deduplicate the request. // When realtime-ticking, realtimeTick > 0 forces a fresh fetch. ...( realtimeTick > 0 ? { time: realtimeTick } : {} ), } ); }, [ period, resolution, realtimeTick ] ); const isLoading = useSelect( select => { return select( 'accelerate' ).getIsLoadingStats(); }, [ data ] ); const statsError = useSelect( select => { return select( 'accelerate' ).getStatsError(); }, [ isLoading ] ); // Stale-while-revalidate: hold the last good payload across realtime // refetches so the chart can interpolate between snapshots instead of // blanking to the empty fallback whenever the cache key bumps. Keyed // by period+resolution so a switch to a different window doesn't // show the previous window's data while fetching. const swrKey = `${ period.value || '' }:${ resolution }`; const lastDataRef = useRef( undefined ); const lastDataKeyRef = useRef( '' ); if ( data && Object.values( data?.by_interval || {} ).length > 0 ) { lastDataRef.current = data; lastDataKeyRef.current = swrKey; } const effectiveData = data && Object.values( data?.by_interval || {} ).length > 0 ? data : ( lastDataKeyRef.current === swrKey ? lastDataRef.current : undefined ); // eslint-disable-next-line react-hooks/exhaustive-deps let uniques: Datum[] = []; const hasData = Object.values( effectiveData?.by_interval || {} ).length > 0; // Don't show loading state visuals during realtime refresh when we already have data. const showLoadingState = isLoading && ! ( isRealTime && hasData ); // True only on the first load before any data arrives — used to suppress chart // axes/grid while the skeleton overlay is visible. showLoadingState is too broad // here (it's also true during background refreshes with existing data). const showChartSkeleton = ! hasData && isLoading; // Only show placeholder when there's no data yet. During realtime refresh, keep showing existing data. if ( ! hasData ) { if ( isRealTime ) { // Empty-state placeholder for the realtime view: 15 zero // buckets so the chart silhouette matches the visible // window above (15 min). Once real data lands, the older // 15 minutes of data are still fetched but clip off-screen. uniques = Array( 15 ) .fill( {} ) .map( ( _, i ) => { return { time: moment() .subtract( 15 - i, 'minutes' ) .toDate(), uniques: 0, views: 0, }; } ); } else { uniques = Array( 7 ) .fill( {} ) .map( ( d, i ) => { return { time: moment().endOf( 'day' ).subtract( 7, 'days' ).add( i, 'days' ).toDate(), uniques: 0, views: 0, }; } ); } } else { const dateNow = new Date( displayNow ); uniques = Object.entries( effectiveData?.by_interval || {} ).map( ( [ time, stats ] ) => { const date: Date = new Date( time.replace( ' ', 'T' ) + 'Z' ); return { time: date < dateNow ? date : dateNow, uniques: stats?.visitors || 0, views: stats?.views || 0, }; } ); // Realtime drops everything older than the visible window; // without this trim, visx draws the off-screen-left buckets // and the line + gradient bleed past the y-axis gutter. One // extra bucket of margin keeps the leftmost segment landing // cleanly on the chart edge as it scrolls off. if ( isRealTime ) { const visibleStart = displayNow - ( REALTIME_VISIBLE_MINUTES * 60 * 1000 ) - ( 60 * 1000 ); uniques = uniques.filter( d => d.time.getTime() >= visibleStart ); // Anchor the newest bucket to displayNow so it always sits // at the right edge — its value pulses up as the current // minute fills, instead of the whole point drifting left // until a new bucket pops in further right. On bucket // rollover the previously-anchored point reverts to its // real time and snaps into its true position; the snap // reads as "new point arrived, prior one slid into place". uniques.sort( ( a, b ) => a.time.getTime() - b.time.getTime() ); const last = uniques[ uniques.length - 1 ]; if ( last ) { last.time = new Date( displayNow ); } } } useEffect( () => { const el = document.getElementById( 'hero-chart' ); if ( ! el ) { return; } // Initial measurement. setOuterWidth( el.offsetWidth ); // Recalc on any container resize — fullscreen toggle, sidebar // collapse, window resize. const obs = new ResizeObserver( entries => { const w = entries[ 0 ]?.contentRect.width; if ( w ) { setOuterWidth( Math.round( w ) ); } } ); obs.observe( el ); return () => obs.disconnect(); }, [ setOuterWidth ] ); // Reset interval on period change if not available. useEffect( () => { if ( period.intervals.map( i => i.interval ).indexOf( resolution ) === -1 ) { setResolution( period.intervals[period.defaultInterval || 0].interval ); } }, [ period, resolution, setResolution ] ); const dateDomain = extent( uniques, getX ) as [Date, Date]; if ( resolution.match( /PT\d+H/ ) || resolution.match( /\d+ hour/ ) ) { dateDomain[1] = moment( dateDomain[1] ).add( 1, 'hours' ).toDate(); // Ensure last data point is not left out by visx. } // Realtime: pin the scale's right edge to wall-clock now and the // left edge to now-30min so the line continuously drifts left as // displayNow advances — the full PT30M fetch window is visible. // At ~1200px wide that's ~0.7 px/sec of drift, visible without // feeling frantic, and the window covers enough recent activity // for wall-display context. if ( isRealTime ) { const visibleWindowMs = REALTIME_VISIBLE_MINUTES * 60 * 1000; dateDomain[ 0 ] = new Date( displayNow - visibleWindowMs ); dateDomain[ 1 ] = new Date( displayNow ); } const xScale = scaleTime( { domain: dateDomain, } ); const yScaleMax = Math.max( 4, ( max( uniques, getViews ) as number ) + Math.floor( ( max( uniques, getViews ) as number ) / 6 ) ); // yScale only depends on data magnitude, not on displayNow — safe to memoize // so the RAF ticker doesn't recompute it every 100 ms. const yScale = useMemo( () => scaleLinear( { domain: [ 0, yScaleMax ], nice: true, } ), [ yScaleMax ] ); const graphHeight = 250; // Left gutter holds the rotated "VIEW COUNT" label (flush with the // content-area left edge), the tick numbers, and a little breathing // room. Tightened from 150 so the chart fills the content width and // aligns with the tile/list containers. const offsetleft = 70; const graphPaddingX = 30; const graphPaddingY = 35; // Right padding is small — just enough that the last data point isn't // flush against the SVG edge. const outerWidthWithOffset = Math.max( 0, outerWidth - offsetleft - 20 ); xScale.range( [ 0, outerWidthWithOffset ] ); yScale.range( [ graphHeight, 0 ] ); const { showTooltip, hideTooltip, tooltipData, tooltipTop = 0, tooltipLeft = 0 } = useTooltip(); const handleTooltip = useCallback( ( event: React.TouchEvent | React.MouseEvent ) => { const { x } = localPoint( event ) || { x: 0 }; const x0 = xScale.invert( x - offsetleft ); const index = bisectDate( uniques, x0, 1 ); const d0 = uniques[ index - 1 ]; const d1 = uniques[ index ]; let d = d0; if ( d1 && getX( d1 ) ) { d = x0.valueOf() - getX( d0 ).valueOf() > getX( d1 ).valueOf() - x0.valueOf() ? d1 : d0; } showTooltip( { tooltipData: d, tooltipLeft: xScale( getX( d ) ), tooltipTop: yScale( getViews( d ) ), } ); }, [ showTooltip, yScale, xScale, uniques ] ); // Fade-in dot key: bumped only when a brand new bucket joins the // server data (minute boundary in realtime). Stable within a minute // so the dot doesn't re-flash on every rAF tick. Falls back to the // last point's index if the data shape isn't ready. const lastBucketKey = effectiveData ? Object.keys( effectiveData.by_interval || {} ).slice( -1 )[ 0 ] ?? '' : ''; const reduced = useReducedMotion(); const lastPoint = uniques.length > 0 ? uniques[ uniques.length - 1 ] : null; const lastDotX = lastPoint ? xScale( getX( lastPoint ) ) : null; const lastDotY = lastPoint ? yScale( getViews( lastPoint ) ) : null; const showDot = isRealTime && hasData && lastPoint != null && Number.isFinite( lastDotX as number ) && Number.isFinite( lastDotY as number ); // For realtime, the chart's main fetch covers the last 30 min, but // the visible window + the strip's "vs. prior" comparison are both // scoped to the last 15 min. Override the strip values with a // dedicated 15-min fetch in that case. Non-realtime keeps using // the chart's summary directly. const realtimeCurrent = useRealtimeCurrentSummary( isRealTime, realtimeTick ); const baseSummary = effectiveData?.stats?.summary; const stripVisitors = isRealTime ? realtimeCurrent?.visitors ?? 0 : baseSummary?.visitors ?? 0; const stripViews = isRealTime ? realtimeCurrent?.views ?? 0 : baseSummary?.views ?? 0; const stripBounce = isRealTime ? realtimeCurrent?.bounce ?? 0 : baseSummary?.bounce ?? 0; const visitors = stripVisitors; const views = stripViews; const pagesPerVisit = visitors > 0 ? views / visitors : 0; const bouncePct = visitors > 0 ? ( stripBounce / visitors ) * 100 : 0; // Show KPI skeletons only on the genuine first-load (no data yet // AND a fetch is in flight). A loaded payload with all-zero counts // is still data — render `0` for each KPI, don't loop on skeletons. const headerHasData = Boolean( effectiveData ); const showHeaderSkeleton = ! headerHasData && isLoading; const showLoadingPill = isLoading; // Prior-period fetch for the metric strip deltas. Skipped for PT30M // (realtime) — usePriorSummary returns null in that case so each // tile renders the neutral em-dash. const prior = usePriorSummary( period.value || 'P7D', realtimeTick ); const priorVisitors = prior?.visitors ?? 0; const priorViews = prior?.views ?? 0; const priorPagesPerVisit = priorVisitors > 0 ? priorViews / priorVisitors : 0; const priorBouncePct = priorVisitors > 0 ? ( ( prior?.bounce ?? 0 ) / priorVisitors ) * 100 : 0; const visitorsDelta = prior ? computeDelta( visitors, priorVisitors ) : undefined; const viewsDelta = prior ? computeDelta( views, priorViews ) : undefined; const pagesPerVisitDelta = prior ? computeDelta( Math.round( pagesPerVisit * 10 ), Math.round( priorPagesPerVisit * 10 ) ) : undefined; const bounceDelta = prior ? computeDelta( Math.round( bouncePct ), Math.round( priorBouncePct ) ) : undefined; const engagedSecondsAvg = baseSummary?.engaged?.avg_seconds ?? 0; return (
{ showHeaderSkeleton ? ( { [ 0, 1, 2, 3, 4 ].map( i => (
) ) }
) : ( ) }
{ period.intervals.length > 1 && (

{ isRealTime ? __( 'Live activity', 'altis' ) : __( 'Traffic', 'altis' ) }

( { value: i.interval, label: i.label, } ) ) } value={ resolution } onValueChange={ ( value: string ) => { trackEvent( 'content_explorer_resolution_changed', { resolution: value } ); setResolution( value ); } } />
) }
{ showLoadingPill && (
{ __( 'Loading data', 'altis' ) }
) } { ! hasData && ! isLoading && statsError && (
) } { ! hasData && isLoading && ( // Chart silhouette skeleton — y-tick stubs on the left, // three staggered horizontal bars hinting at a rising // line, and an axis line at the bottom. Reads as // "a chart is loading" rather than a flat placeholder.
); }