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