Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.62% covered (success)
93.62%
176 / 188
80.65% covered (warning)
80.65%
25 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Analytics
93.62% covered (success)
93.62%
176 / 188
80.65% covered (warning)
80.65%
25 / 31
65.07
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 is_initialized
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_dashboard_surface
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 renders_admin_chrome
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 init_wpcom_simple
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 apply_options
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_pre_connection_stats_page_replacement
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 boot_shared_services
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 dashboard_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_script_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_script_data
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 site_timezone
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 format_gmt_offset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 register_sync_bootstrap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 register_local_api
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 load_dashboard_components
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 register_dashboard_support_routes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load_build
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 remove_full_page_interceptor
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
5.20
 widget_manifest_path
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_admin_page
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 redirect_denied_dashboard_request
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 is_dashboard_request
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_admin_menu
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 register_replayed_admin_menu
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 add_dashboard_menu_entry
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 render_missing_build_notice
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 menu_title
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 enqueue_i18n_loader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_tracks_transport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add_tracks_identity_script_data
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Analytics package main class.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Export;
13use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller;
14use Automattic\Jetpack\PremiumAnalytics\REST\Notices_Controller;
15use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker;
16use Automattic\Jetpack\Status\Host;
17use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
18
19/**
20 * Main Analytics class.
21 *
22 * Loads the wp-build output and registers the dashboard's admin page.
23 */
24class Analytics {
25
26    const PACKAGE_VERSION = '0.16.0';
27
28    /**
29     * Whether the class has been initialized.
30     *
31     * @var bool
32     */
33    private static $initialized = false;
34
35    /**
36     * Menu title override for the admin page. Null falls back to the package's own
37     * translated label, resolved on admin_menu — init runs far too early to translate.
38     *
39     * @var string|\Closure|null
40     */
41    private static $menu_title = null;
42
43    /**
44     * The menu label once resolved, so the menu and the missing-build notice can't
45     * disagree if a caller hands us a closure that returns something different
46     * each call. Reset whenever $menu_title is assigned.
47     *
48     * @var string|null
49     */
50    private static $resolved_menu_title = null;
51
52    /**
53     * Whether the host has this dashboard replace stats-admin's pre-connection Stats page.
54     *
55     * @var bool
56     */
57    private static $replaces_pre_connection_stats_page = false;
58
59    /**
60     * Path to the wp-build entry point. Null uses the generated build.
61     *
62     * A test seam: `build/` is gitignored and test-php runs no build step, so tests redirect this
63     * instead. Private, so — unlike the widget manifest's path — it needs no filter to stay out of reach.
64     *
65     * @var string|null
66     */
67    private static $build_entry = null;
68
69    /**
70     * Initialize the Analytics app on a connected Jetpack site.
71     *
72     * Registers the full local surface: the site serves the WPCOM data proxy,
73     * notices, sync bootstrap, and the dashboard support routes itself.
74     *
75     * Hosts call this on every request once the flag is on, never only on admin ones: the
76     * store-event tracker listens on the front end. {@see self::load_dashboard_surface()} is what
77     * keeps the admin-only work off those requests.
78     *
79     * @param array $options Optional configuration options.
80     *                       Supported keys:
81     *                       - menu_title (string|\Closure): Admin menu label. Defaults to
82     *                         the package's own translated label. Pass a closure to supply
83     *                         a translated label of your own: it runs on admin_menu, where
84     *                         a textdomain can load, unlike init time.
85     *                       - replaces_pre_connection_stats_page (bool): Stop stats-admin from
86     *                         registering its own Stats page while the site is not connected, so
87     *                         this dashboard is the only Stats entry. Defaults to whether classic
88     *                         Stats is retired.
89     *                       A later call still applies its options, so a host that calls this after
90     *                       another plugin did keeps its configuration.
91     * @return void
92     */
93    public static function init( $options = array() ) {
94        self::apply_options( $options );
95        if ( self::$initialized ) {
96            return;
97        }
98        self::$initialized = true;
99
100        self::register_sync_bootstrap();
101        self::register_local_api();
102
103        // Piggybacks on the Jetpack Stats module; checks Jetpack connection state.
104        Jetpack_Stats_Tracker::configure();
105        self::register_pre_connection_stats_page_replacement();
106
107        self::boot_shared_services();
108        self::register_dashboard_support_routes();
109        self::load_dashboard_surface();
110    }
111
112    /**
113     * Whether Stats v2 booted in this request, whichever plugin or platform booted it.
114     *
115     * @since 0.16.0
116     *
117     * @return bool
118     */
119    public static function is_initialized() {
120        return self::$initialized;
121    }
122
123    /**
124     * Load the dashboard render surface, on the requests that can render it.
125     *
126     * With the rollout flag on, init() runs on every request — including every WPCOM public-api
127     * request on Simple — so this stays gated for visitors who never use it (REST excluded; see load_build()).
128     *
129     * @return void
130     */
131    private static function load_dashboard_surface() {
132        if ( ! self::renders_admin_chrome() ) {
133            // Calypso's sidebar replays wp-admin/menu.php over REST, where nothing else lists the dashboard.
134            add_action( 'admin_menu', array( static::class, 'register_replayed_admin_menu' ) );
135            return;
136        }
137
138        self::load_dashboard_components();
139        self::load_build();
140        self::remove_full_page_interceptor();
141        self::register_admin_page();
142    }
143
144    /**
145     * Whether this request can render an admin screen.
146     *
147     * Core also sets is_admin() on admin-ajax.php and admin-post.php, which render no dashboard
148     * and get no handlers from this package, so neither needs the build parsed.
149     *
150     * @return bool
151     */
152    private static function renders_admin_chrome() {
153        if ( ! is_admin() || wp_doing_ajax() ) {
154            return false;
155        }
156
157        // wp-includes/vars.php sets $pagenow before plugins load.
158        return 'admin-post.php' !== ( $GLOBALS['pagenow'] ?? '' );
159    }
160
161    /**
162     * Initialize the Analytics app on WordPress.com Simple.
163     *
164     * Simple reaches public-api.wordpress.com directly via WPCOM's apiFetch bridge, registering
165     * no local REST surface (no proxy, notices, sync bootstrap, or dashboard routes) — WPCOM handles those.
166     *
167     * @param array $options Optional configuration options.
168     *                       Supported keys:
169     *                       - menu_title (string|\Closure): Admin menu label. Defaults to
170     *                         the package's own translated label. Pass a closure to supply
171     *                         a translated label of your own: it runs on admin_menu, where
172     *                         a textdomain can load, unlike init time.
173     * @return void
174     */
175    public static function init_wpcom_simple( $options = array() ) {
176        self::apply_options( $options );
177        if ( self::$initialized ) {
178            return;
179        }
180        self::$initialized = true;
181
182        self::boot_shared_services();
183        self::load_dashboard_surface();
184    }
185
186    /**
187     * Apply init-time configuration options.
188     *
189     * @param array $options Options passed to the init entry points.
190     * @return void
191     */
192    private static function apply_options( $options ) {
193        if ( ! empty( $options['menu_title'] ) ) {
194            self::$menu_title          = $options['menu_title'];
195            self::$resolved_menu_title = null;
196        }
197        if ( ! empty( $options['replaces_pre_connection_stats_page'] ) ) {
198            self::$replaces_pre_connection_stats_page = true;
199        }
200    }
201
202    /**
203     * Replace stats-admin's pre-connection Stats page with this dashboard when the host asks, or once classic Stats is retired.
204     *
205     * The callback reads the option when stats-admin initializes on `plugins_loaded`, so a host whose
206     * init() call comes after another plugin's still takes effect.
207     *
208     * @return void
209     */
210    private static function register_pre_connection_stats_page_replacement() {
211        add_filter(
212            'jetpack_stats_admin_pre_connection_dashboard',
213            static function ( $register ) {
214                return self::$replaces_pre_connection_stats_page || Classic_Stats::is_retired() ? false : $register;
215            }
216        );
217    }
218
219    /**
220     * Boot the services every platform needs, whether or not the site serves the
221     * dashboard support routes itself.
222     *
223     * @return void
224     */
225    private static function boot_shared_services() {
226        // Must be hooked before admin_menu and rest_api_init check the capability.
227        Capabilities::register();
228
229        // Emit WooCommerce store events into the Woo pipeline (ClickHouse + proxy).
230        WooCommerce_Analytics_Tracker::configure();
231
232        // CSV report export pipeline (WOOA7S-1581): hooks rest_api_init, so it must
233        // register on all requests. Self-gates on WooCommerce + Jetpack connection.
234        Export::configure();
235
236        self::register_script_data();
237
238        // Stats links elsewhere (post list table, admin bar, action bar) open this dashboard.
239        Stats_Links::register();
240    }
241
242    /**
243     * URL of a dashboard route on this site.
244     *
245     * The SPA path travels in `p`, encoded here since add_query_arg() leaves values alone and a
246     * raw `?` inside it would read as an outer query param.
247     *
248     * @since 0.4.0
249     *
250     * @param string $path Route path, e.g. `/post/123`.
251     * @return string
252     */
253    public static function dashboard_url( $path = '/' ) {
254        return admin_url( 'admin.php?page=' . self::MENU_PAGE_SLUG . '&p=' . rawurlencode( $path ) );
255    }
256
257    /**
258     * Announce to Jetpack's other surfaces that this dashboard is the site's analytics UI,
259     * so they link here instead of the Stats page.
260     *
261     * @return void
262     */
263    private static function register_script_data() {
264        add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_script_data' ) );
265    }
266
267    /**
268     * Runs on nearly every admin page load, so the payload stays to two strings,
269     * a bool, and one access check.
270     *
271     * `can_view` is whether this user can open a Stats view: every link other surfaces build from it opens one.
272     *
273     * @param array $data The script data.
274     * @return array The script data with the analytics key added.
275     */
276    public static function add_script_data( $data ) {
277        $data['analytics'] = array(
278            'enabled'   => true,
279            'page_slug' => self::MENU_PAGE_SLUG,
280            'can_view'  => Capabilities::current_user_can_open_stats(),
281            'timezone'  => self::site_timezone(),
282        );
283
284        return $data;
285    }
286
287    /**
288     * Prefers `timezone_string` over `gmt_offset`, matching the dashboard's own `siteTimeZone()`:
289     * analytics links point at past dates, so a fixed offset applied to the far side of a
290     * daylight-saving transition shifts the day.
291     *
292     * @return string An IANA timezone name, or a `+HH:MM` UTC offset.
293     */
294    private static function site_timezone() {
295        $timezone_string = get_option( 'timezone_string' );
296
297        if ( is_string( $timezone_string ) && $timezone_string !== '' ) {
298            return $timezone_string;
299        }
300
301        return self::format_gmt_offset( (float) get_option( 'gmt_offset' ) );
302    }
303
304    /**
305     * Format a GMT offset in hours as `+HH:MM`.
306     *
307     * @param float $offset The offset in hours, e.g. 5.5 or -8.
308     * @return string The formatted offset.
309     */
310    private static function format_gmt_offset( $offset ) {
311        $sign     = $offset < 0 ? '-' : '+';
312        $absolute = abs( $offset );
313        $hours    = (int) floor( $absolute );
314        $minutes  = (int) round( ( $absolute - $hours ) * 60 );
315
316        return sprintf( '%s%02d:%02d', $sign, $hours, $minutes );
317    }
318
319    /**
320     * Register the sync services that feed the local data pipeline.
321     *
322     * @return void
323     */
324    private static function register_sync_bootstrap() {
325        // Keep the shared connection available when another connection-owning plugin is deactivated.
326        Connection_Configuration::configure();
327
328        Sync_Status_Tracker::configure();
329    }
330
331    /**
332     * Register the site-served REST API: the WPCOM data proxy, notices and the Stats settings.
333     *
334     * Each self-gates on its own rest_api_init hook.
335     *
336     * @return void
337     */
338    private static function register_local_api() {
339        Api_Proxy_Controller::register();
340        Notices_Controller::register();
341        Stats_Settings::configure();
342    }
343
344    /**
345     * Load the dashboard components every platform renders with.
346     *
347     * Admin-only, via load_dashboard_surface(); boot_routes() requires these
348     * again for REST.
349     *
350     * @return void
351     */
352    private static function load_dashboard_components() {
353        // Widget modules for the client's dynamic import() map.
354        require_once __DIR__ . '/widget-modules.php';
355
356        // Default layout primitives and the bundled defaults' seed.
357        require_once __DIR__ . '/dashboard-layout.php';
358
359        // Dashboard section API, then the package's own sections registered through it.
360        require_once __DIR__ . '/dashboard-sections.php';
361        require_once __DIR__ . '/default-dashboard-sections.php';
362        configure_dashboard_sections_script_data();
363
364        // Single-report page API, then the package's own pages registered through it.
365        require_once __DIR__ . '/single-report-pages.php';
366        require_once __DIR__ . '/default-single-report-pages.php';
367        configure_single_report_pages_script_data();
368
369        // Default-on CSV export settings and server-side disable filter.
370        require_once __DIR__ . '/csv-exports.php';
371        configure_csv_exports();
372
373        // VideoPress availability for the client's video routes. The widget layer
374        // reads the same signal through widget-type-support.php.
375        require_once __DIR__ . '/videopress-availability.php';
376        configure_videopress_availability();
377
378        // The plan's locked Stats features, for the client's locked states.
379        require_once __DIR__ . '/stats-plan-gates.php';
380        configure_stats_plan_gates();
381
382        // The purchase a Stats checkout returned with, for the thank-you notice.
383        require_once __DIR__ . '/purchase-success.php';
384        configure_purchase_success();
385
386        // When the site first opened the dashboard, for notices that wait a while before showing.
387        require_once __DIR__ . '/dashboard-first-visit.php';
388        configure_dashboard_first_visit();
389
390        require_once __DIR__ . '/assets-url.php';
391        configure_assets_url();
392
393        // Temporary: carries a default layout move into layouts readers customized before it.
394        Stored_Layout_Move::register();
395    }
396
397    /**
398     * Serve the dashboard support routes from the site. Simple skips this —
399     * WPCOM calls Dashboard_Support_Routes::register() itself instead.
400     *
401     * @return void
402     */
403    private static function register_dashboard_support_routes() {
404        Dashboard_Support_Routes::register();
405    }
406
407    /**
408     * Load the wp-build output (interceptor, modules, routes, page render).
409     *
410     * Admin-only, via load_dashboard_surface(). REST does not need it:
411     * boot_routes() and ensure_widget_registry_ready() load what they use.
412     *
413     * @return void
414     */
415    private static function load_build() {
416        $build_entry = self::$build_entry ?? __DIR__ . '/../build/build.php';
417        if ( file_exists( $build_entry ) ) {
418            require_once $build_entry;
419            require_once __DIR__ . '/sdk-module.php';
420        }
421    }
422
423    /**
424     * Unhook wp-build's full-page render interceptor — security-relevant: it renders
425     * `?page=jetpack-premium-analytics` from admin_init with no capability check, and only
426     * renders_admin_chrome() gates the admin-post.php/admin-ajax.php paths that reach admin_init
427     * without Core's own slug check.
428     *
429     * Because remove_action() no-ops on a callback name it can't find, a wp-build rename would
430     * silently restore this entry point — hence the _doing_it_wrong() below when that happens.
431     *
432     * @return void
433     */
434    private static function remove_full_page_interceptor() {
435        if ( remove_action( 'admin_init', 'jpa_jetpack_premium_analytics_intercept_render' ) ) {
436            return;
437        }
438
439        if ( function_exists( 'jpa_jetpack_premium_analytics_intercept_render' ) ) {
440            _doing_it_wrong(
441                __METHOD__,
442                'The Premium Analytics full-page interceptor could not be unhooked: wp-build changed the generated callback name or its admin_init priority.',
443                ''
444            );
445        }
446    }
447
448    /**
449     * Absolute path to the generated widget manifest.
450     *
451     * @return string
452     */
453    public static function widget_manifest_path() {
454        /**
455         * Filters the path to the generated widget manifest.
456         *
457         * @param string $path Absolute path to the generated widget manifest.
458         */
459        return apply_filters(
460            'jetpack_premium_analytics_widgets_manifest_path',
461            __DIR__ . '/../build/widgets.php'
462        );
463    }
464
465    /**
466     * Register the admin-only render path: polyfills, menu, and page hooks.
467     *
468     * @return void
469     */
470    private static function register_admin_page() {
471        // Polyfills force-replace core handles (wp-private-apis) on wp_default_scripts;
472        // scope to the dashboard page so no other admin page (e.g. block editor) is hit.
473        if ( self::is_dashboard_request() ) {
474            WP_Build_Polyfills::register(
475                'jetpack-premium-analytics',
476                array_merge(
477                    WP_Build_Polyfills::SCRIPT_HANDLES,
478                    WP_Build_Polyfills::MODULE_IDS
479                )
480            );
481
482            add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_i18n_loader' ) );
483            add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_tracks_transport' ) );
484            add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_tracks_identity_script_data' ), 20 );
485            add_action( 'admin_page_access_denied', array( static::class, 'redirect_denied_dashboard_request' ) );
486        }
487
488        add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) );
489    }
490
491    /**
492     * Sends a reader who lost access, for example because the site was disconnected, to the
493     * wp-admin Dashboard instead of the "not allowed" screen.
494     *
495     * @return never
496     */
497    public static function redirect_denied_dashboard_request() {
498        wp_safe_redirect( admin_url() );
499        exit;
500    }
501
502    /**
503     * The admin page slug the dashboard menu registers. Published in script data
504     * so no caller has to hard-code it.
505     */
506    const MENU_PAGE_SLUG = 'jetpack-premium-analytics-wp-admin';
507
508    /**
509     * Whether the current request is rendering the Premium Analytics dashboard.
510     *
511     * Scopes the wp-build polyfill registration (which force-replaces core script handles) to
512     * this dashboard; reads the menu slug directly, not current_screen, to stay safe at plugin-load time.
513     *
514     * @return bool True when serving the dashboard page in wp-admin.
515     */
516    public static function is_dashboard_request() {
517        if ( ! is_admin() ) {
518            return false;
519        }
520
521        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the menu page slug to scope asset loading; no state is changed.
522        $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
523
524        return self::MENU_PAGE_SLUG === $page;
525    }
526
527    /**
528     * Register the admin menu page.
529     *
530     * Uses wp-build's `-wp-admin` variant so Core applies the menu capability check. Reports the
531     * page and widget artifacts independently since the build loader includes each conditionally.
532     *
533     * Queued through Admin_Menu rather than registered here, so the entry is reachable by the
534     * `jetpack_admin_menu_visibility` filter.
535     *
536     * @return void
537     */
538    public static function register_admin_menu() {
539        $can_render          = function_exists( 'jpa_jetpack_premium_analytics_wp_admin_render_page' );
540        $has_widget_manifest = file_exists( self::widget_manifest_path() );
541
542        $missing = array();
543        if ( ! $can_render ) {
544            // Named by symbol, not by file: build/pages.php is only a loader, and the
545            // callback can also go missing to a renamed page slug or an absent build entry.
546            $missing[] = 'the jpa_jetpack_premium_analytics_wp_admin_render_page() callback, generated under build/pages/';
547        }
548        if ( ! $has_widget_manifest ) {
549            $missing[] = 'build/widgets.php (the widget manifest)';
550        }
551
552        if ( $missing ) {
553            // Surfaced here rather than only on the page itself, so a partial deploy shows up on
554            // the first admin request instead of waiting for someone to open the dashboard.
555            _doing_it_wrong(
556                __METHOD__,
557                // esc_html() only to satisfy WordPress.Security.EscapeOutput, which treats
558                // this argument as output; every entry is a literal from just above.
559                'The Premium Analytics build output is incomplete: ' . esc_html( implode( ', ', $missing ) ) . '. The package build did not run, or ran only partially, for this deploy.',
560                ''
561            );
562        }
563
564        $render_callback = $can_render
565            ? 'jpa_jetpack_premium_analytics_wp_admin_render_page'
566            : array( __CLASS__, 'render_missing_build_notice' );
567
568        self::add_dashboard_menu_entry( $render_callback );
569    }
570
571    /**
572     * List the dashboard in a menu built outside wp-admin, such as the one the admin-menu REST endpoint returns.
573     *
574     * Loads the section registry but not the build: the menu capability depends on the sections.
575     *
576     * @since $$next-version$$
577     *
578     * @return void
579     */
580    public static function register_replayed_admin_menu() {
581        require_once __DIR__ . '/dashboard-layout.php';
582        require_once __DIR__ . '/default-dashboard-sections.php';
583
584        self::add_dashboard_menu_entry( '__return_null' );
585    }
586
587    /**
588     * Add the dashboard's top-level menu entry.
589     *
590     * @param callable $render_callback Renders the page.
591     * @return void
592     */
593    private static function add_dashboard_menu_entry( $render_callback ) {
594        $menu_title = esc_html( self::menu_title() );
595        $capability = Capabilities::dashboard_page_capability();
596
597        // An older admin-ui, loaded first by another plugin, may predate add_top_level_menu().
598        if ( ! method_exists( Admin_Menu::class, 'add_top_level_menu' ) ) {
599            add_menu_page( $menu_title, $menu_title, $capability, self::MENU_PAGE_SLUG, $render_callback, 'dashicons-chart-bar', 2 );
600            return;
601        }
602
603        // A fixed key rather than the slug, which carries a build-specific suffix. No gate:
604        // the dashboard has no My Jetpack product class and no module to name.
605        Admin_Menu::add_top_level_menu( $menu_title, $menu_title, $capability, self::MENU_PAGE_SLUG, $render_callback, 'dashicons-chart-bar', 2, array( 'key' => 'jetpack-premium-analytics' ) );
606    }
607
608    /**
609     * Stand-in for the generated render callback when the build output is absent.
610     *
611     * The PHP classes come from Composer and the build output from pnpm, so a
612     * partial deploy can leave the class loadable with nothing to render.
613     *
614     * @return void
615     */
616    public static function render_missing_build_notice() {
617        printf(
618            '<div class="wrap"><h1>%s</h1><p>%s</p></div>',
619            esc_html( self::menu_title() ),
620            esc_html__( 'The Premium Analytics assets are missing. The package build did not run for this deploy.', 'jetpack-premium-analytics-pkg' )
621        );
622    }
623
624    /**
625     * The caller's menu label override, or the package's own translated label.
626     *
627     * Call only once translations can load — admin_menu or later — and memoize so every call site
628     * agrees. Deliberately not is_callable(): PHP function names are case-insensitive, so a plain
629     * label like "Analytics" could match a stray analytics() function and get called.
630     *
631     * @return string
632     */
633    private static function menu_title() {
634        if ( null !== self::$resolved_menu_title ) {
635            return self::$resolved_menu_title;
636        }
637
638        $title = self::$menu_title instanceof \Closure
639            ? ( self::$menu_title )()
640            : self::$menu_title;
641
642        // A positive check rather than a null coalesce: a closure may return an empty string, or
643        // something that isn't a string at all, and either would reach esc_html() as a broken label.
644        if ( is_string( $title ) && '' !== $title ) {
645            self::$resolved_menu_title = $title;
646        } elseif ( Classic_Stats::is_retired() ) {
647            // No classic Stats menu is left to tell apart from.
648            self::$resolved_menu_title = __( 'Stats', 'jetpack-premium-analytics-pkg' );
649        } else {
650            self::$resolved_menu_title = __( 'Stats v2', 'jetpack-premium-analytics-pkg' );
651        }
652
653        return self::$resolved_menu_title;
654    }
655
656    /**
657     * Enqueue the i18n loader so the wp-build init module can download its JS
658     * translation catalogs. It's registered on every admin page by jetpack-assets
659     * but only enqueued when depended on; the esbuild bundles don't pull it in.
660     *
661     * @return void
662     */
663    public static function enqueue_i18n_loader() {
664        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
665            wp_enqueue_script( 'wp-jp-i18n-loader' );
666        }
667    }
668
669    /**
670     * Load the Tracks transport for the dashboard.
671     *
672     * `@automattic/jetpack-analytics` only queues events into `window._tkq` — its own w.js
673     * loader is disabled — so without this handle no `jetpack_premium_analytics_*` event
674     * ever flushes. Simple is skipped because stats.php already prints the same script.
675     *
676     * @return void
677     */
678    public static function enqueue_tracks_transport() {
679        if ( ( new Host() )->is_wpcom_simple() ) {
680            return;
681        }
682
683        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
684    }
685
686    /**
687     * Publish the WPCOM identity the dashboard attributes its Tracks events to.
688     *
689     * Core's script data carries only the local user. Publicize is the one package that fills
690     * `current_user.wpcom` in, and the standalone plugin does not bundle it, so without this
691     * every event would land anonymous there.
692     *
693     * @param array $data The script data.
694     * @return array The script data with the WPCOM identity added.
695     */
696    public static function add_tracks_identity_script_data( $data ) {
697        if ( ( new Host() )->is_wpcom_simple() ) {
698            $wpcom_user = array(
699                'ID'    => get_current_user_id(),
700                'login' => wp_get_current_user()->user_login,
701            );
702        } else {
703            $connected = ( new Connection_Manager() )->get_connected_user_data();
704
705            if ( empty( $connected['ID'] ) || empty( $connected['login'] ) ) {
706                return $data;
707            }
708
709            // Only the two fields `identifyUser` needs: the rest of the connected-user payload
710            // is profile data the dashboard never reads.
711            $wpcom_user = array(
712                'ID'    => $connected['ID'],
713                'login' => $connected['login'],
714            );
715        }
716
717        $data['user']['current_user']['wpcom'] = array_merge(
718            $data['user']['current_user']['wpcom'] ?? array(),
719            $wpcom_user
720        );
721
722        return $data;
723    }
724}