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