import { ChevronsUpDown, X } from 'lucide-react'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import React, { useEffect, useRef, useState } from 'react'; import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { Post } from '../../audiences/types'; import FilterEditor from '../../dashboard/components/FilterEditor'; import FilterSummary from '../../dashboard/components/FilterSummary'; import { store } from '../../data'; import { usePersistentState } from '../../data/hooks'; import { Duration, Filter } from '../../utils/admin'; import { StickyPeriodBar, REALTIME_INTERVAL_SECONDS } from '../components/shared/StickyPeriodBar'; import BreakdownPanel from './BreakdownPanel'; import HeroChart from './HeroChart'; import { Button } from '@/components/ui/button'; // Minimal structural deep-equal for plain-JSON audience objects. Avoids a // `lodash` external that is present in the analytics (dashboard) bundle but // NOT the home-dashboard (accelerate) bundle this shared component now also // loads into — importing it there throws "lodash is not defined" at module // eval and blanks the page. Order-insensitive over object keys, matching the // isEqual semantics this replaces. function deepEqual( a: unknown, b: unknown ): boolean { if ( a === b ) { return true; } if ( typeof a !== 'object' || typeof b !== 'object' || a === null || b === null ) { return false; } const ak = Object.keys( a as Record ); const bk = Object.keys( b as Record ); if ( ak.length !== bk.length ) { return false; } return ak.every( k => deepEqual( ( a as Record )[ k ], ( b as Record )[ k ] ) ); } // Cross-fade the chart + BreakdownPanel when the period changes. The // min-height floor matches the typical combined height (chart ~340 + // gap + 4-tile panel ~360) so the page below doesn't jump while a // new period's payload is mid-fetch. Reduced motion skips the // AnimatePresence entirely and renders children directly. function PeriodCrossfade( { periodKey, children }: { periodKey: Duration; children: React.ReactNode } ) { const reduced = useReducedMotion(); if ( reduced ) { return
{ children }
; } return (
{ children }
); } interface Props { // Key for the persisted period selection. Distinct per surface so the // Dashboard and Analytics page remember their own period independently. periodPersistKey: string; // When true, render the audience/filter row and thread the resulting // filter into the body. When false/absent the filter is `{}` and no row // renders — the body behaves byte-identically to the unfiltered view. showFilters?: boolean; // Fullscreen is owned by the surface wrapper (one useFullscreenMode // instance per surface) and passed in, so the Dashboard can gate its // onboarding on the SAME state the period bar's toggle drives. isFullscreen: boolean; onToggleFullscreen: () => void; } /** * Shared analytics body used by both the WP-home Dashboard and the Analytics * page. Owns period state, the realtime-tick chain, the period bar, an * optional filter/audience row, and the HeroChart + BreakdownPanel. */ export default function AnalyticsView( { periodPersistKey, showFilters = false, isFullscreen, onToggleFullscreen }: Props ) { const [ period, setPeriod ] = usePersistentState( periodPersistKey, 'P7D' ); // Filter/audience state — only meaningful when showFilters is set. When // filters are hidden the filter stays `{}` and nothing downstream changes. const [ filter, setFilter ] = useState( {} ); const [ showingEditor, setShowingEditor ] = useState( false ); const [ nextFilter, setNextFilter ] = useState( {} ); // Realtime tick — fires REALTIME_INTERVAL_SECONDS after each load completes. // ringTick increments on load-complete so the ring resets at that moment, // giving an accurate "X seconds until next refresh" countdown. const isRealtime = period === 'PT30M'; const [ realtimeTick, setRealtimeTick ] = useState( 0 ); const [ ringTick, setRingTick ] = useState( 0 ); const isLoadingStats = useSelect( select => select( store ).getIsLoadingStats() ); const prevLoading = useRef( isLoadingStats ); // Re-entering realtime: past ticks are cached by the data store, so the // stats selector returns the stale resolved payload without fetching — // no load-complete transition ever fires, and the polling chain below // never starts. Bump the tick to force a fresh fetch and restart the // chain. First-ever entry ( tick === 0 ) fetches uncached naturally. // Bumped here rather than in an effect so the period and tick updates // batch into one render — an effect leaves an intermediate render on // the stale tick, double-firing tick-keyed fetches downstream. const changePeriod = ( p: Duration ) => { if ( p === 'PT30M' && period !== 'PT30M' ) { setRealtimeTick( t => ( t > 0 ? t + 1 : t ) ); } setPeriod( p ); }; useEffect( () => { const wasLoading = prevLoading.current; prevLoading.current = isLoadingStats; if ( ! isRealtime ) { return; } // Schedule the next tick once a load transitions from in-progress → done. if ( wasLoading && ! isLoadingStats ) { setRingTick( t => t + 1 ); const id = setTimeout( () => setRealtimeTick( t => t + 1 ), REALTIME_INTERVAL_SECONDS * 1000 ); return () => clearTimeout( id ); } }, [ isRealtime, isLoadingStats ] ); const currentAudience = useSelect( select => ( filter.audienceId ? select( store ).getPost( filter.audienceId ) : null ), [ filter ] ); const isModified = ! currentAudience || ! deepEqual( filter.audience, currentAudience.audience ); const hasFilters = !! filter.path || !! filter.audience; const onOpenFilters = () => { setNextFilter( filter ); setShowingEditor( true ); }; return (
{ showFilters && (
{ hasFilters && ( ) }

{ currentAudience ? ( <> { __( 'Selected audience', 'altis' ) }{ ' ' } { currentAudience.title.rendered } { isModified ? __( ' (modified)', 'altis' ) : '' } ) : ( <> { __( 'Selected audience', 'altis' ) }{ ' ' } { __( 'All visitors', 'altis' ) } ) }

) }
{ showFilters && ( ) }
); }