From fb5082e0387e031ad6848e8830eb5fce3d1651e8 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Thu, 30 Jul 2026 14:15:47 +0200 Subject: [PATCH 1/8] Premium Analytics: point Jetpack's analytics links at the new dashboard Seven links across Jetpack still pointed at the legacy Stats page. Rather than spell a URL at each call site, add getAnalyticsUrl() to script-data: callers describe where they want to go and the helper resolves which analytics UI the site runs, the page slug, the site and blog identifiers, the section vocabulary, and the date encoding. Premium Analytics announces itself by publishing an `analytics` key on jetpack_admin_js_script_data. Its presence is the branch signal, so no caller has to ask Jetpack about the feature flag; when absent, the helper builds the legacy Stats URLs byte-for-byte as before. Notable details: - The dashboard keeps its whole router path in a single `p` search param, and reads from/to as offset-bearing instants in the site timezone. A bare YYYY-MM-DD would be parsed as UTC midnight and land a day early west of UTC, so the helper takes calendar days and owns the encoding, reading the zone offset at each boundary so a DST-transition day gets the right offset on each end. - At-a-Glance chart bars now open the period the bar stands for. The old link ignored the Days/Weeks/Months tab and always deep-linked one day. - getAnalyticsUrl() returns null when the user lacks the capability to open the dashboard, and every call site hides its control rather than rendering a dead link. - The plan-usage upgrade CTA is removed. It pointed at a Stats tier purchase screen that was a Calypso route inside the CDN-served Odyssey bundle, so it left with that dashboard and has no counterpart here. - My Jetpack's Stats get_purchase_url() returns null, the base-class default, which falls back to the existing /add-stats interstitial. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQezwoePExAo1sc1onk7fB --- pnpm-lock.yaml | 15 + ...-premium-analytics-update-jetpack-surfaces | 4 + .../js-packages/script-data/composer.json | 5 +- .../js-packages/script-data/jest.config.cjs | 5 + projects/js-packages/script-data/package.json | 9 +- .../script-data/src/analytics-url.ts | 340 +++++++++++++++ projects/js-packages/script-data/src/index.ts | 1 + .../src/test/analytics-url.test.ts | 410 ++++++++++++++++++ projects/js-packages/script-data/src/types.ts | 89 ++++ .../_inc/components/stats-section/index.jsx | 18 +- ...-premium-analytics-update-jetpack-surfaces | 4 + .../my-jetpack/src/products/class-stats.php | 46 ++ .../tests/php/Stats_Product_Test.php | 126 ++++++ .../tests/php/assets/jetpack-mock-plugin.txt | 9 + ...-premium-analytics-update-jetpack-surfaces | 4 + ...alytics-update-jetpack-surfaces-plan-usage | 4 + .../premium-analytics/src/class-analytics.php | 90 +++- .../tests/php/Analytics_Test.php | 109 +++++ .../plan-usage/__tests__/plan-usage.test.tsx | 42 +- .../widgets/plan-usage/render.tsx | 51 +-- .../stories/plan-usage-widget.stories.tsx | 8 +- .../at-a-glance/stats/chart-bar-range.js | 57 +++ .../at-a-glance/stats/dash-stats-bottom.jsx | 21 +- .../_inc/client/at-a-glance/stats/index.jsx | 15 +- .../at-a-glance/stats/test/chart-bar-range.js | 85 ++++ ...-premium-analytics-update-jetpack-surfaces | 4 + .../memberships/subscribers-affirmation.jsx | 21 +- .../test/subscribers-affirmation-test.js | 29 +- .../src/components/newsletter-widget.tsx | 36 +- .../newsletter-widget/src/helpers.ts | 16 +- .../newsletter-widget/test/helpers.test.ts | 33 +- .../test/newsletter-widget.test.tsx | 46 +- .../plugins/jetpack/tests/jest.config.gui.js | 4 + 33 files changed, 1624 insertions(+), 132 deletions(-) create mode 100644 projects/js-packages/script-data/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces create mode 100644 projects/js-packages/script-data/jest.config.cjs create mode 100644 projects/js-packages/script-data/src/analytics-url.ts create mode 100644 projects/js-packages/script-data/src/test/analytics-url.test.ts create mode 100644 projects/packages/my-jetpack/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces create mode 100644 projects/packages/my-jetpack/tests/php/Stats_Product_Test.php create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces-plan-usage create mode 100644 projects/plugins/jetpack/_inc/client/at-a-glance/stats/chart-bar-range.js create mode 100644 projects/plugins/jetpack/_inc/client/at-a-glance/stats/test/chart-bar-range.js create mode 100644 projects/plugins/jetpack/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4e5bb131cb3..207ddfacfd0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1391,9 +1391,24 @@ importers: projects/js-packages/script-data: devDependencies: + '@babel/core': + specifier: 7.29.7 + version: 7.29.7 + '@jest/globals': + specifier: 30.4.1 + version: 30.4.1 + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 + jest: + specifier: 30.4.2 + version: 30.4.2 + jetpack-js-tools: + specifier: workspace:* + version: link:../../../tools/js-tools projects/js-packages/shared-extension-utils: dependencies: diff --git a/projects/js-packages/script-data/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces b/projects/js-packages/script-data/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces new file mode 100644 index 000000000000..3a9ba6e8658c --- /dev/null +++ b/projects/js-packages/script-data/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add getAnalyticsUrl() for building links into the site's analytics dashboard. diff --git a/projects/js-packages/script-data/composer.json b/projects/js-packages/script-data/composer.json index 8d76fa81f4d1..57192ba03088 100644 --- a/projects/js-packages/script-data/composer.json +++ b/projects/js-packages/script-data/composer.json @@ -4,7 +4,10 @@ "type": "library", "license": "GPL-2.0-or-later", "require": {}, - "scripts": {}, + "scripts": { + "test-js": "pnpm run test", + "test-js-coverage": "pnpm run test-coverage" + }, "repositories": [ { "type": "path", diff --git a/projects/js-packages/script-data/jest.config.cjs b/projects/js-packages/script-data/jest.config.cjs new file mode 100644 index 000000000000..1b43920c9794 --- /dev/null +++ b/projects/js-packages/script-data/jest.config.cjs @@ -0,0 +1,5 @@ +const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' ); + +module.exports = { + ...baseConfig, +}; diff --git a/projects/js-packages/script-data/package.json b/projects/js-packages/script-data/package.json index e5898765fabd..0ab1f6e0e20b 100644 --- a/projects/js-packages/script-data/package.json +++ b/projects/js-packages/script-data/package.json @@ -17,9 +17,16 @@ ".": "./src/index.ts" }, "scripts": { + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test-coverage": "pnpm run test --coverage", "typecheck": "tsgo --noEmit" }, "devDependencies": { - "@typescript/native-preview": "7.0.0-dev.20260707.2" + "@babel/core": "7.29.7", + "@jest/globals": "30.4.1", + "@types/jest": "30.0.0", + "@typescript/native-preview": "7.0.0-dev.20260707.2", + "jest": "30.4.2", + "jetpack-js-tools": "workspace:*" } } diff --git a/projects/js-packages/script-data/src/analytics-url.ts b/projects/js-packages/script-data/src/analytics-url.ts new file mode 100644 index 000000000000..b9083330c761 --- /dev/null +++ b/projects/js-packages/script-data/src/analytics-url.ts @@ -0,0 +1,340 @@ +import { getScriptData, getAdminUrl } from './utils.ts'; +import type { + AnalyticsDateRange, + AnalyticsPostSection, + AnalyticsScriptData, + AnalyticsView, +} from './types.ts'; + +/** + * The legacy Stats page slug. Registered by the Stats admin package, and still + * the analytics UI on any site where Premium Analytics is not the dashboard. + */ +const STATS_PAGE_SLUG = 'stats'; + +/** + * Premium Analytics' dashboard sections, keyed by the neutral section name + * callers use. The dashboard reads `?section=` as a section *slug* — the + * segment after the namespace in the server-side registry (`analytics/traffic` + * is registered, `traffic` is what reaches the URL). + */ +const PA_DASHBOARD_SECTIONS = { + traffic: 'traffic', + insights: 'insights', + subscribers: 'subscribers', + store: 'store', +} as const; + +/** + * Premium Analytics' post-detail tabs, keyed by the neutral section name. + * Callers say `traffic`; the tab layout registry calls it `post-traffic`. + */ +const PA_POST_SECTIONS: Record< AnalyticsPostSection, string > = { + traffic: 'post-traffic', + 'email-opens': 'email-opens', + 'email-clicks': 'email-clicks', +}; + +/** + * Reads the `analytics` script data published by the Premium Analytics package. + * + * The package only publishes this on sites where it *is* the analytics UI, so + * its presence is the branch signal: present means build Premium Analytics + * routes, absent means build legacy Stats routes. + * + * @return The analytics script data, or undefined on a legacy Stats site. + */ +function getAnalyticsScriptData(): AnalyticsScriptData | undefined { + const analytics = getScriptData()?.analytics; + + return analytics?.enabled ? analytics : undefined; +} + +/** + * Normalizes a fixed UTC offset into the `+HH:MM` form the analytics URL + * grammar uses, or returns undefined when the value is not a fixed offset (an + * IANA zone name such as `America/New_York`). + * + * @param timezone - The site timezone, either an IANA name or a fixed offset. + * @return The normalized offset, or undefined for an IANA zone name. + */ +function toFixedOffset( timezone: string ): string | undefined { + if ( timezone === 'UTC' || timezone === 'Z' ) { + return '+00:00'; + } + + const match = /^([+-])(\d{1,2}):?(\d{2})?$/.exec( timezone.trim() ); + if ( ! match ) { + return undefined; + } + + const [ , sign, hours, minutes = '00' ] = match; + + return `${ sign }${ hours.padStart( 2, '0' ) }:${ minutes }`; +} + +/** + * The site's UTC offset at a given instant, as `+HH:MM`. + * + * `longOffset` renders as `GMT-04:00`, or a bare `GMT` when the zone is exactly + * UTC at that instant. + * + * @param timezone - An IANA timezone name. + * @param instant - The instant to read the offset at, in epoch milliseconds. + * @return The UTC offset, or undefined when the zone name is unusable. + */ +function offsetAt( timezone: string, instant: number ): string | undefined { + let name: string | undefined; + try { + name = new Intl.DateTimeFormat( 'en-US', { timeZone: timezone, timeZoneName: 'longOffset' } ) + .formatToParts( new Date( instant ) ) + .find( part => part.type === 'timeZoneName' )?.value; + } catch { + // An unknown zone name throws a RangeError. + return undefined; + } + + if ( name === 'GMT' ) { + return '+00:00'; + } + + return name?.startsWith( 'GMT' ) ? toFixedOffset( name.slice( 3 ) ) : undefined; +} + +/** + * Converts an offset in `+HH:MM` form to minutes east of UTC. + * + * @param offset - The offset to convert. + * @return The offset in minutes. + */ +function offsetToMinutes( offset: string ): number { + const sign = offset.startsWith( '-' ) ? -1 : 1; + const [ hours, minutes ] = offset.slice( 1 ).split( ':' ); + + return sign * ( Number( hours ) * 60 + Number( minutes ) ); +} + +/** + * Encodes a calendar day as the offset-bearing ISO timestamp the analytics + * dashboard writes to its own URL (`yyyy-MM-dd'T'HH:mm:ss.SSSxxx`, in the site + * timezone). The date picker parses `from`/`to` as instants, so a bare + * `YYYY-MM-DD` would be read as UTC midnight and land on the previous day for + * any site west of UTC. + * + * @param day - The calendar day, `YYYY-MM-DD` in the site's timezone. + * @param boundary - Which end of the day to encode. + * @param timezone - The site timezone, either an IANA name or a fixed offset. + * @return The encoded timestamp, or undefined when the day cannot be encoded. + */ +function encodeDay( day: string, boundary: 'start' | 'end', timezone: string ): string | undefined { + if ( ! /^\d{4}-\d{2}-\d{2}$/.test( day ) ) { + return undefined; + } + + const time = boundary === 'start' ? '00:00:00.000' : '23:59:59.999'; + + const fixed = toFixedOffset( timezone ); + if ( fixed ) { + return `${ day }T${ time }${ fixed }`; + } + + /* + * The wall-clock time is known but its instant is not, and the offset of an + * IANA zone depends on the instant. Read the offset once treating the wall + * clock as UTC, use it to place the real instant, then read the offset + * again there. The second read is the offset actually in effect at this + * wall-clock time, which matters on a DST-transition day: the offset at + * local midnight can differ from the one later the same day. + */ + const naive = Date.parse( `${ day }T${ time }Z` ); + const guess = offsetAt( timezone, naive ); + if ( ! guess ) { + return undefined; + } + + const offset = offsetAt( timezone, naive - offsetToMinutes( guess ) * 60_000 ) ?? guess; + + return `${ day }T${ time }${ offset }`; +} + +/** + * Builds the `from`/`to` search params for a date range, dropping the range + * entirely if either boundary cannot be encoded — a half-applied range would + * silently widen the window the dashboard shows. + * + * `interval`, `preset` and the comparison params are deliberately left off: the + * dashboard's own route seeds an interval valid for the range, and omitting + * `preset` keeps the range a custom one rather than forcing a comparison the + * caller never asked for. + * + * @param range - The requested range. + * @param timezone - The site timezone. + * @return The search params to merge, empty when the range cannot be encoded. + */ +function rangeParams( range: AnalyticsDateRange, timezone: string ): Record< string, string > { + const from = encodeDay( range.from, 'start', timezone ); + const to = encodeDay( range.to, 'end', timezone ); + + return from && to ? { from, to } : {}; +} + +/** + * Builds the internal router path for a view, or undefined when the view has no + * Premium Analytics route. + * + * @param view - The requested view. + * @return The internal path, e.g. `/reports/emails`. + */ +function paPath( view: AnalyticsView ): string | undefined { + switch ( view.view ) { + case 'dashboard': + return '/'; + case 'report': + return view.report ? `/reports/${ encodeURIComponent( view.report ) }` : undefined; + case 'post': + return view.id > 0 ? `/post/${ view.id }` : undefined; + case 'video': + return view.id > 0 ? `/video/${ view.id }` : undefined; + default: + return undefined; + } +} + +/** + * Builds the `?section=` param for a view, translating the caller's neutral + * section name into the slug the matching Premium Analytics route reads. + * + * An unrecognized section is dropped rather than passed through: each route + * resolves an unknown `?section=` back to its default tab anyway, so forwarding + * one only puts a dead value in a shareable URL. + * + * @param view - The requested view. + * @return The search params to merge. + */ +function paSection( view: AnalyticsView ): Record< string, string > { + if ( view.view === 'dashboard' ) { + const section = view.section && PA_DASHBOARD_SECTIONS[ view.section ]; + + return section ? { section } : {}; + } + + if ( view.view === 'post' ) { + const section = view.section && PA_POST_SECTIONS[ view.section ]; + + return section ? { section } : {}; + } + + // Reports own their section vocabulary, so pass the caller's value through. + if ( view.view === 'report' && view.section ) { + return { section: view.section }; + } + + return {}; +} + +/** + * Builds a Premium Analytics URL. + * + * The dashboard is a single admin page running a client-side router, and + * `@wordpress/boot` keeps that router's whole path-and-search in a single `p` + * query param rather than a hash — so the internal path is built first and then + * encoded into `p`. + * + * @param view - The requested view. + * @param analytics - The analytics script data. + * @return The URL, or null when the view has no route. + */ +function premiumAnalyticsUrl( view: AnalyticsView, analytics: AnalyticsScriptData ): string | null { + const path = paPath( view ); + if ( ! path ) { + return null; + } + + const search = new URLSearchParams( { + ...paSection( view ), + ...( view.range ? rangeParams( view.range, analytics.timezone ) : {} ), + } ); + const query = search.toString(); + + const page = new URLSearchParams( { + page: analytics.page_slug, + p: query ? `${ path }?${ query }` : path, + } ); + + return getAdminUrl( `admin.php?${ page }` ); +} + +/** + * Builds a legacy Stats URL. + * + * Stats runs the Calypso-derived Odyssey bundle behind a hash route, and only + * ever exposed a handful of deep-link shapes. Views it has no counterpart for + * fall back to the Stats page root, which is a working — if undeep — landing + * spot, rather than null: the link is still worth showing. + * + * @param view - The requested view. + * @return The URL. + */ +function statsUrl( view: AnalyticsView ): string { + const root = getAdminUrl( `admin.php?page=${ STATS_PAGE_SLUG }` ); + const site = getScriptData()?.site?.suffix; + const blogId = getScriptData()?.site?.wpcom?.blog_id; + + if ( view.view === 'dashboard' && site ) { + if ( view.section === 'subscribers' ) { + return `${ root }#!/stats/subscribers/${ site }`; + } + + if ( view.section === 'traffic' && view.range?.from ) { + /* + * Odyssey reads `startDate` as an instant, and the Stats API reports + * calendar days, so the day is encoded as UTC midnight — the same + * encoding the chart itself uses for its labels. + */ + const startDate = `${ view.range.from }T00:00:00.000Z`; + + return `${ root }#!/stats/day/${ site }?startDate=${ startDate }`; + } + } + + if ( view.view === 'post' && view.id > 0 && blogId && view.section === 'email-opens' ) { + return `${ root }#!/stats/email/opens/day/${ view.id }/${ blogId }`; + } + + return root; +} + +/** + * Builds the URL for an analytics destination, on whichever analytics UI this + * site runs. + * + * Callers describe *where they want to go*, not how to spell it: the site's + * analytics UI (Premium Analytics or legacy Stats), the page slug, the site and + * blog identifiers, the section vocabulary and the date encoding are all + * resolved here. That keeps one translation table per UI instead of a URL + * template at every call site. + * + * Returns null when there is nowhere to send the user — either the view has no + * route, or the current user cannot open the analytics dashboard. Callers + * should hide the control rather than render a dead link. + * + * @param view - The requested view. + * @return The URL, or null when the control should be hidden. + * + * @example + * getAnalyticsUrl( { view: 'dashboard', section: 'subscribers' } ); + * getAnalyticsUrl( { view: 'post', id: 42, section: 'email-opens' } ); + */ +export function getAnalyticsUrl( view: AnalyticsView ): string | null { + const analytics = getAnalyticsScriptData(); + + if ( ! analytics ) { + return statsUrl( view ); + } + + if ( ! analytics.can_view ) { + return null; + } + + return premiumAnalyticsUrl( view, analytics ); +} diff --git a/projects/js-packages/script-data/src/index.ts b/projects/js-packages/script-data/src/index.ts index 99a3479d7b95..fcd7ed1a350a 100644 --- a/projects/js-packages/script-data/src/index.ts +++ b/projects/js-packages/script-data/src/index.ts @@ -1,2 +1,3 @@ +export * from './analytics-url.ts'; export * from './types.ts'; export * from './utils.ts'; diff --git a/projects/js-packages/script-data/src/test/analytics-url.test.ts b/projects/js-packages/script-data/src/test/analytics-url.test.ts new file mode 100644 index 000000000000..790c1f835cb0 --- /dev/null +++ b/projects/js-packages/script-data/src/test/analytics-url.test.ts @@ -0,0 +1,410 @@ +import { getAnalyticsUrl } from '../analytics-url.ts'; +import type { AnalyticsScriptData, JetpackScriptData } from '../types.ts'; + +const ADMIN_URL = 'https://example.com/wp-admin/'; +const PA_PAGE = 'jetpack-premium-analytics-wp-admin'; + +/** + * Seeds `window.JetpackScriptData` with the site fields `getAnalyticsUrl()` + * reads, plus an optional `analytics` payload. Omitting `analytics` is the + * legacy-Stats site. + * + * @param analytics - The analytics payload, or undefined for a Stats site. + * @param site - Site fields to override. + */ +function seedScriptData( + analytics?: Partial< AnalyticsScriptData >, + site: Record< string, unknown > = {} +) { + window.JetpackScriptData = { + site: { + admin_url: ADMIN_URL, + suffix: 'example.com', + wpcom: { blog_id: 12345 }, + ...site, + }, + user: { current_user: { capabilities: {} } }, + ...( analytics + ? { + analytics: { + enabled: true, + page_slug: PA_PAGE, + can_view: true, + timezone: 'UTC', + ...analytics, + }, + } + : {} ), + } as unknown as JetpackScriptData; +} + +/** + * Resolves a Premium Analytics URL to the path and search the router ends up + * with, so assertions read the values the route receives rather than an encoded + * URL string. + * + * The dashboard keeps its whole path-and-search inside the single `p` query + * param, so the router decodes twice: once pulling `p` off the page URL, and + * once parsing `p`'s own search. This mirrors both steps. + * + * @param url - The URL to parse. + * @return The decoded path and search, e.g. `/?section=traffic`. + */ +function routerPath( url: string | null ): string | null { + if ( ! url ) { + return null; + } + + // Step one: the browser decodes `p` off the page URL. + const path = new URL( url ).searchParams.get( 'p' ); + if ( ! path ) { + return null; + } + + // Step two: the router parses `p`'s search the same way. + const [ pathname, search ] = path.split( '?' ); + if ( ! search ) { + return pathname; + } + + const params = [ ...new URLSearchParams( search ) ] + .map( ( [ key, value ] ) => `${ key }=${ value }` ) + .join( '&' ); + + return `${ pathname }?${ params }`; +} + +afterEach( () => { + delete window.JetpackScriptData; +} ); + +describe( 'getAnalyticsUrl on a Premium Analytics site', () => { + it( 'points at the Premium Analytics page, not the Stats page', () => { + seedScriptData( {} ); + + const url = getAnalyticsUrl( { view: 'dashboard' } ); + + expect( url ).toBe( `${ ADMIN_URL }admin.php?page=${ PA_PAGE }&p=%2F` ); + expect( url ).not.toContain( 'page=stats' ); + } ); + + it( 'omits an empty search rather than trailing a bare "?"', () => { + seedScriptData( {} ); + + expect( routerPath( getAnalyticsUrl( { view: 'dashboard' } ) ) ).toBe( '/' ); + } ); + + it( 'encodes the inner search twice, matching the two decodes the router does', () => { + seedScriptData( { timezone: 'UTC' } ); + + const raw = getAnalyticsUrl( { + view: 'dashboard', + range: { from: '2026-07-01', to: '2026-07-01' }, + } ) as string; + + // The `+` of the UTC offset must survive as `%252B` in the page URL: one + // layer for `p`'s own search, one for `p` itself. A single layer would + // decode to a space and the date picker would reject the range. + expect( raw ).toContain( '%252B00%253A00' ); + expect( new URL( raw ).searchParams.get( 'p' ) ).toContain( '%2B00%3A00' ); + } ); + + describe( 'view routing', () => { + it.each( [ + [ 'dashboard', { view: 'dashboard' as const }, '/' ], + [ 'report', { view: 'report' as const, report: 'emails' as const }, '/reports/emails' ], + [ 'post', { view: 'post' as const, id: 42 }, '/post/42' ], + [ 'video', { view: 'video' as const, id: 7 }, '/video/7' ], + ] )( 'routes the %s view to %s', ( _label, view, expected ) => { + seedScriptData( {} ); + + expect( routerPath( getAnalyticsUrl( view ) ) ).toBe( expected ); + } ); + + it.each( [ + [ 'a zero post id', { view: 'post' as const, id: 0 } ], + [ 'a negative post id', { view: 'post' as const, id: -1 } ], + [ 'a zero video id', { view: 'video' as const, id: 0 } ], + ] )( 'returns null for %s', ( _label, view ) => { + seedScriptData( {} ); + + expect( getAnalyticsUrl( view ) ).toBeNull(); + } ); + } ); + + describe( 'section translation', () => { + it.each( [ + [ 'traffic', 'traffic' ], + [ 'insights', 'insights' ], + [ 'subscribers', 'subscribers' ], + [ 'store', 'store' ], + ] as const )( 'maps the dashboard %s section to ?section=%s', ( section, expected ) => { + seedScriptData( {} ); + + expect( routerPath( getAnalyticsUrl( { view: 'dashboard', section } ) ) ).toBe( + `/?section=${ expected }` + ); + } ); + + it( 'translates the neutral post "traffic" section to the route\'s own slug', () => { + seedScriptData( {} ); + + expect( routerPath( getAnalyticsUrl( { view: 'post', id: 9, section: 'traffic' } ) ) ).toBe( + '/post/9?section=post-traffic' + ); + } ); + + it.each( [ 'email-opens', 'email-clicks' ] as const )( + 'passes the post %s section through', + section => { + seedScriptData( {} ); + + expect( routerPath( getAnalyticsUrl( { view: 'post', id: 9, section } ) ) ).toBe( + `/post/9?section=${ section }` + ); + } + ); + + it( 'passes a report section through, since reports own their vocabulary', () => { + seedScriptData( {} ); + + expect( + routerPath( getAnalyticsUrl( { view: 'report', report: 'utm', section: 'campaigns' } ) ) + ).toBe( '/reports/utm?section=campaigns' ); + } ); + + it( 'drops an unrecognized dashboard section instead of putting a dead value in the URL', () => { + seedScriptData( {} ); + + const view = { view: 'dashboard', section: 'nope' } as unknown as Parameters< + typeof getAnalyticsUrl + >[ 0 ]; + + expect( routerPath( getAnalyticsUrl( view ) ) ).toBe( '/' ); + } ); + } ); + + describe( 'date range encoding', () => { + it( 'encodes whole days as the offset-bearing timestamps the dashboard writes itself', () => { + seedScriptData( { timezone: 'UTC' } ); + + expect( + routerPath( + getAnalyticsUrl( { + view: 'dashboard', + range: { from: '2026-07-01', to: '2026-07-31' }, + } ) + ) + ).toBe( '/?from=2026-07-01T00:00:00.000+00:00&to=2026-07-31T23:59:59.999+00:00' ); + } ); + + it( 'uses the site timezone offset, not UTC', () => { + seedScriptData( { timezone: 'America/New_York' } ); + + expect( + routerPath( + getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-07-04', to: '2026-07-04' } } ) + ) + ).toBe( '/?from=2026-07-04T00:00:00.000-04:00&to=2026-07-04T23:59:59.999-04:00' ); + } ); + + it( 'accepts a fixed UTC offset timezone', () => { + seedScriptData( { timezone: '+05:30' } ); + + expect( + routerPath( + getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-07-04', to: '2026-07-04' } } ) + ) + ).toBe( '/?from=2026-07-04T00:00:00.000+05:30&to=2026-07-04T23:59:59.999+05:30' ); + } ); + + it( 'pads a single-digit fixed offset', () => { + seedScriptData( { timezone: '-8:00' } ); + + expect( + routerPath( + getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-07-04', to: '2026-07-04' } } ) + ) + ).toContain( 'from=2026-07-04T00:00:00.000-08:00' ); + } ); + + it( 'uses the offset in effect at each boundary across a DST transition', () => { + seedScriptData( { timezone: 'America/New_York' } ); + + // 2026-03-08 is the US spring-forward date: the range opens on EST + // and closes on EDT. + expect( + routerPath( + getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-03-08', to: '2026-03-08' } } ) + ) + ).toBe( '/?from=2026-03-08T00:00:00.000-05:00&to=2026-03-08T23:59:59.999-04:00' ); + } ); + + it( 'carries a range on reports and post detail too, not just the dashboard', () => { + seedScriptData( { timezone: 'UTC' } ); + + expect( + routerPath( + getAnalyticsUrl( { + view: 'report', + report: 'posts', + range: { from: '2026-01-01', to: '2026-01-31' }, + } ) + ) + ).toBe( + '/reports/posts?from=2026-01-01T00:00:00.000+00:00&to=2026-01-31T23:59:59.999+00:00' + ); + + expect( + routerPath( + getAnalyticsUrl( { + view: 'post', + id: 5, + range: { from: '2026-01-01', to: '2026-01-01' }, + } ) + ) + ).toBe( '/post/5?from=2026-01-01T00:00:00.000+00:00&to=2026-01-01T23:59:59.999+00:00' ); + } ); + + it( 'never sets interval, preset or comparison params', () => { + seedScriptData( {} ); + + const path = routerPath( + getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-07-01', to: '2026-07-31' } } ) + ); + + expect( path ).not.toContain( 'interval=' ); + expect( path ).not.toContain( 'preset=' ); + expect( path ).not.toContain( 'comp=' ); + } ); + + it.each( [ + [ 'a malformed from', { from: '07/01/2026', to: '2026-07-31' } ], + [ 'a malformed to', { from: '2026-07-01', to: 'yesterday' } ], + [ 'an empty range', { from: '', to: '' } ], + ] )( 'drops the whole range for %s rather than half-applying it', ( _label, range ) => { + seedScriptData( {} ); + + const path = routerPath( getAnalyticsUrl( { view: 'dashboard', range } ) ); + + expect( path ).toBe( '/' ); + } ); + + it( 'drops the range when the timezone is unusable', () => { + seedScriptData( { timezone: 'Not/AZone' } ); + + expect( + routerPath( + getAnalyticsUrl( { view: 'dashboard', range: { from: '2026-07-01', to: '2026-07-31' } } ) + ) + ).toBe( '/' ); + } ); + + it( 'keeps the section when the range is dropped', () => { + seedScriptData( {} ); + + expect( + routerPath( + getAnalyticsUrl( { + view: 'dashboard', + section: 'traffic', + range: { from: 'nope', to: 'nope' }, + } ) + ) + ).toBe( '/?section=traffic' ); + } ); + } ); + + describe( 'capability', () => { + it( 'returns null when the user cannot open the dashboard', () => { + seedScriptData( { can_view: false } ); + + expect( getAnalyticsUrl( { view: 'dashboard' } ) ).toBeNull(); + } ); + + it( 'returns null for every view when the user cannot view', () => { + seedScriptData( { can_view: false } ); + + expect( getAnalyticsUrl( { view: 'report', report: 'emails' } ) ).toBeNull(); + expect( getAnalyticsUrl( { view: 'post', id: 1, section: 'email-opens' } ) ).toBeNull(); + expect( getAnalyticsUrl( { view: 'video', id: 1 } ) ).toBeNull(); + } ); + } ); + + it( 'falls back to the Stats grammar when the payload is present but disabled', () => { + seedScriptData( { enabled: false } ); + + expect( getAnalyticsUrl( { view: 'dashboard' } ) ).toBe( `${ ADMIN_URL }admin.php?page=stats` ); + } ); +} ); + +describe( 'getAnalyticsUrl on a legacy Stats site', () => { + it( 'links to the Stats page root for the plain dashboard', () => { + seedScriptData(); + + expect( getAnalyticsUrl( { view: 'dashboard' } ) ).toBe( `${ ADMIN_URL }admin.php?page=stats` ); + } ); + + it( 'builds the subscribers deep link', () => { + seedScriptData(); + + expect( getAnalyticsUrl( { view: 'dashboard', section: 'subscribers' } ) ).toBe( + `${ ADMIN_URL }admin.php?page=stats#!/stats/subscribers/example.com` + ); + } ); + + it( 'builds the traffic deep link with the day encoded as UTC midnight', () => { + seedScriptData(); + + expect( + getAnalyticsUrl( { + view: 'dashboard', + section: 'traffic', + range: { from: '2026-07-29', to: '2026-08-04' }, + } ) + ).toBe( + `${ ADMIN_URL }admin.php?page=stats#!/stats/day/example.com?startDate=2026-07-29T00:00:00.000Z` + ); + } ); + + it( 'builds the email-opens deep link from the post and blog ids', () => { + seedScriptData(); + + expect( getAnalyticsUrl( { view: 'post', id: 99, section: 'email-opens' } ) ).toBe( + `${ ADMIN_URL }admin.php?page=stats#!/stats/email/opens/day/99/12345` + ); + } ); + + it.each( [ + [ 'a report', { view: 'report' as const, report: 'utm' as const } ], + [ 'a video', { view: 'video' as const, id: 3 } ], + [ 'an insights section', { view: 'dashboard' as const, section: 'insights' as const } ], + [ 'a post traffic tab', { view: 'post' as const, id: 3, section: 'traffic' as const } ], + ] )( 'falls back to the Stats root for %s, which it cannot deep-link', ( _label, view ) => { + seedScriptData(); + + expect( getAnalyticsUrl( view ) ).toBe( `${ ADMIN_URL }admin.php?page=stats` ); + } ); + + it( 'falls back to the root when the site suffix is missing', () => { + seedScriptData( undefined, { suffix: undefined } ); + + expect( getAnalyticsUrl( { view: 'dashboard', section: 'subscribers' } ) ).toBe( + `${ ADMIN_URL }admin.php?page=stats` + ); + } ); + + it( 'falls back to the root when the blog id is missing', () => { + seedScriptData( undefined, { wpcom: { blog_id: 0 } } ); + + expect( getAnalyticsUrl( { view: 'post', id: 99, section: 'email-opens' } ) ).toBe( + `${ ADMIN_URL }admin.php?page=stats` + ); + } ); + + it( 'never gates on capability, since the Stats page has its own', () => { + seedScriptData(); + + expect( getAnalyticsUrl( { view: 'dashboard' } ) ).not.toBeNull(); + } ); +} ); diff --git a/projects/js-packages/script-data/src/types.ts b/projects/js-packages/script-data/src/types.ts index 2c800fa04046..308d91efdfea 100644 --- a/projects/js-packages/script-data/src/types.ts +++ b/projects/js-packages/script-data/src/types.ts @@ -51,9 +51,98 @@ export interface UserData { current_user: CurrentUserData; } +/** + * Analytics dashboard data, published by the Premium Analytics package only on + * sites where it *is* the analytics UI. Its absence means the site still runs + * the legacy Stats dashboard, so `getAnalyticsUrl()` treats presence as the + * branch signal rather than taking a flag from every caller. + */ +export interface AnalyticsScriptData { + /** + * Whether Premium Analytics is the analytics UI on this site. + */ + enabled: boolean; + + /** + * The admin page slug hosting the dashboard. + */ + page_slug: string; + + /** + * Whether the current user can open the dashboard. False means every + * analytics link should be hidden rather than leading to a capability error. + */ + can_view: boolean; + + /** + * The site's timezone — an IANA name (`America/New_York`) when one is set, + * otherwise a fixed UTC offset (`+05:30`). Used to encode date ranges the + * way the dashboard writes them to its own URL. + */ + timezone: string; +} + +/** + * A range of whole calendar days in the site's timezone, as `YYYY-MM-DD`. + * Callers pass plain calendar dates; `getAnalyticsUrl()` owns the conversion to + * whatever encoding the destination expects. + */ +export interface AnalyticsDateRange { + from: string; + to: string; +} + +/** + * A section of the analytics dashboard, in caller-facing vocabulary. + */ +export type AnalyticsDashboardSection = 'traffic' | 'insights' | 'subscribers' | 'store'; + +/** + * A tab of the single-post analytics view, in caller-facing vocabulary. + */ +export type AnalyticsPostSection = 'traffic' | 'email-opens' | 'email-clicks'; + +/** + * A standalone analytics report. + */ +export type AnalyticsReportId = + | 'annual-insights' + | 'authors' + | 'clicks' + | 'comment-followers' + | 'comments' + | 'downloads' + | 'emails' + | 'posts' + | 'referrers' + | 'search-terms' + | 'tags' + | 'utm' + | 'videos'; + +/** + * An analytics destination, described in terms of what the user should see + * rather than how the URL is spelled. + * + * A discriminated union rather than a property bag, so that a report id can't + * be passed alongside a post id, and each view only accepts the modifiers that + * apply to it. `range` is accepted on every view because the dashboard, the + * reports and the post detail page all read the same date-window params. + * + * Deliberately absent: the site and blog identifiers (resolved from script + * data, so callers need not thread them through props) and product checkout, + * which shares no path grammar with the analytics views. + */ +export type AnalyticsView = + | { view: 'dashboard'; section?: AnalyticsDashboardSection; range?: AnalyticsDateRange } + | { view: 'report'; report: AnalyticsReportId; section?: string; range?: AnalyticsDateRange } + | { view: 'post'; id: number; section?: AnalyticsPostSection; range?: AnalyticsDateRange } + | { view: 'video'; id: number; range?: AnalyticsDateRange }; + export interface JetpackScriptData { site: SiteData; user: UserData; + analytics?: AnalyticsScriptData; } declare global { diff --git a/projects/packages/my-jetpack/_inc/components/stats-section/index.jsx b/projects/packages/my-jetpack/_inc/components/stats-section/index.jsx index b1cba796e5f2..f2eb8bacdb5e 100644 --- a/projects/packages/my-jetpack/_inc/components/stats-section/index.jsx +++ b/projects/packages/my-jetpack/_inc/components/stats-section/index.jsx @@ -1,3 +1,4 @@ +import { getAnalyticsUrl } from '@automattic/jetpack-script-data'; import { __ } from '@wordpress/i18n'; import { useCallback, useMemo } from 'react'; import { PRODUCT_STATUSES } from '../../constants'; @@ -146,6 +147,11 @@ const StatsSection = () => { const { counts, previousCounts, chartData } = processedData; + // The site's analytics dashboard, or null when there is nowhere to send the + // user — the current user cannot open it. Both the card's own button and the + // chart inside it lead here. + const analyticsUrl = useMemo( () => getAnalyticsUrl( { view: 'dashboard' } ), [] ); + /** * Called when "See detailed stats" button is clicked. */ @@ -154,16 +160,18 @@ const StatsSection = () => { product: slug, } ); - window.location.href = 'admin.php?page=stats&force_refresh=1'; - }, [ recordEvent ] ); + if ( analyticsUrl ) { + window.location.href = analyticsUrl; + } + }, [ recordEvent, analyticsUrl ] ); const shouldShowSecondaryButton = useCallback( - () => !! ( status === PRODUCT_STATUSES.CAN_UPGRADE ), - [ status ] + () => !! ( status === PRODUCT_STATUSES.CAN_UPGRADE && analyticsUrl ), + [ status, analyticsUrl ] ); const viewStatsButton = { - href: 'admin.php?page=stats', + href: analyticsUrl, label: __( 'View detailed stats', 'jetpack-my-jetpack' ), onClick: onDetailedStatsClick, shouldShowButton: shouldShowSecondaryButton, diff --git a/projects/packages/my-jetpack/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces b/projects/packages/my-jetpack/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces new file mode 100644 index 000000000000..0be8384c367a --- /dev/null +++ b/projects/packages/my-jetpack/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Stats: point the product card at the Premium Analytics dashboard when it replaces the Stats page. diff --git a/projects/packages/my-jetpack/src/products/class-stats.php b/projects/packages/my-jetpack/src/products/class-stats.php index cf68c8a86bb9..a32041413597 100644 --- a/projects/packages/my-jetpack/src/products/class-stats.php +++ b/projects/packages/my-jetpack/src/products/class-stats.php @@ -297,12 +297,54 @@ public static function has_trial_support() { return true; } + /** + * The admin page slug of the Premium Analytics dashboard. + * + * Mirrors `Analytics::MENU_PAGE_SLUG`. Spelled out rather than referenced, + * because My Jetpack does not depend on the premium-analytics package — it + * ships in plugins that do not include it at all. + * + * @since $$next-version$$ + */ + const PREMIUM_ANALYTICS_PAGE_SLUG = 'jetpack-premium-analytics-wp-admin'; + + /** + * Whether the Premium Analytics dashboard has replaced the Stats page. + * + * Guarded the same way as the other `class_exists( 'Jetpack' )` checks in this + * package: My Jetpack ships in plugins that do not include the Jetpack plugin, + * and the flag only exists there. + * + * @since $$next-version$$ + * + * @return bool + */ + private static function is_premium_analytics_enabled() { + return class_exists( 'Jetpack' ) + && method_exists( 'Jetpack', 'is_premium_analytics_enabled' ) + && \Jetpack::is_premium_analytics_enabled(); + } + /** * Get the WordPress.com URL for purchasing Jetpack Stats for the current site. * + * Returns null once Premium Analytics has replaced the Stats page. The tier + * purchase screen was a Calypso route inside the CDN-served Odyssey bundle + * rather than a page this repo hosts, so it left with the Stats dashboard and + * has no replacement to point at. + * + * Null is also the default for the base class — "most Jetpack products use an + * interstitial page within My Jetpack" — which makes the action button fall + * back to the `#/add-stats` interstitial that already exists and is complete. + * Stats was the outlier in overriding it. + * * @return ?string */ public static function get_purchase_url() { + if ( self::is_premium_analytics_enabled() ) { + return null; + } + $status = static::get_status(); if ( $status === Products::STATUS_NEEDS_FIRST_SITE_CONNECTION ) { return null; @@ -323,6 +365,10 @@ public static function get_purchase_url() { * @return ?string */ public static function get_manage_url() { + if ( self::is_premium_analytics_enabled() ) { + return admin_url( 'admin.php?page=' . self::PREMIUM_ANALYTICS_PAGE_SLUG ); + } + return admin_url( 'admin.php?page=stats' ); } diff --git a/projects/packages/my-jetpack/tests/php/Stats_Product_Test.php b/projects/packages/my-jetpack/tests/php/Stats_Product_Test.php new file mode 100644 index 000000000000..76091bb47edb --- /dev/null +++ b/projects/packages/my-jetpack/tests/php/Stats_Product_Test.php @@ -0,0 +1,126 @@ +install_mock_plugins(); + wp_cache_delete( 'plugins', 'plugins' ); + activate_plugins( 'jetpack/jetpack.php' ); + + // Mock site connection, so the purchase URL is not short-circuited by the + // needs-first-site-connection status. + ( new Tokens() )->update_blog_token( 'test.test.1' ); + Jetpack_Options::update_option( 'id', 123 ); + + self::$user_id = wp_insert_user( + array( + 'user_login' => 'test_admin', + 'user_pass' => '123', + 'role' => 'administrator', + ) + ); + wp_set_current_user( self::$user_id ); + } + + /** + * Installs the mock plugin present in the test assets folder as the Jetpack plugin. + * + * @return void + */ + public function install_mock_plugins() { + if ( ! file_exists( WP_PLUGIN_DIR . '/jetpack' ) ) { + mkdir( WP_PLUGIN_DIR . '/jetpack', 0777, true ); + } + copy( __DIR__ . '/assets/jetpack-mock-plugin.txt', WP_PLUGIN_DIR . '/jetpack/jetpack.php' ); + } + + /** + * Returning the environment into its initial state. + */ + public function tearDown(): void { + // @phan-suppress-next-line PhanUndeclaredStaticProperty -- It's declared on the mock from ./assets/jetpack-mock-plugin.txt + \Jetpack::$mock_premium_analytics_enabled = false; + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * Put the mocked Jetpack plugin's Premium Analytics flag into a known state. + * + * @param bool $enabled Whether Premium Analytics replaces the Stats page. + */ + private function set_premium_analytics_enabled( $enabled ) { + // @phan-suppress-next-line PhanUndeclaredStaticProperty -- It's declared on the mock from ./assets/jetpack-mock-plugin.txt + \Jetpack::$mock_premium_analytics_enabled = $enabled; + } + + public function test_manage_url_points_at_the_stats_page_by_default() { + $this->set_premium_analytics_enabled( false ); + + $this->assertSame( admin_url( 'admin.php?page=stats' ), Stats::get_manage_url() ); + } + + public function test_manage_url_follows_premium_analytics_when_enabled() { + $this->set_premium_analytics_enabled( true ); + + $manage_url = Stats::get_manage_url(); + + $this->assertSame( admin_url( 'admin.php?page=' . Stats::PREMIUM_ANALYTICS_PAGE_SLUG ), $manage_url ); + $this->assertStringNotContainsString( 'page=stats', (string) $manage_url ); + } + + public function test_purchase_url_points_at_the_stats_purchase_screen_by_default() { + $this->set_premium_analytics_enabled( false ); + + $purchase_url = Stats::get_purchase_url(); + + $this->assertIsString( $purchase_url ); + $this->assertStringContainsString( 'page=stats', $purchase_url ); + $this->assertStringContainsString( '#!/stats/purchase/123', $purchase_url ); + } + + /** + * The tier purchase screen was a Calypso route inside the Odyssey bundle, so + * it left with the Stats dashboard. Returning null — the base class default — + * makes the action button fall back to the `#/add-stats` interstitial instead + * of linking to a route that no longer resolves. + */ + public function test_purchase_url_is_null_when_premium_analytics_is_enabled() { + $this->set_premium_analytics_enabled( true ); + + $this->assertNull( Stats::get_purchase_url() ); + } +} diff --git a/projects/packages/my-jetpack/tests/php/assets/jetpack-mock-plugin.txt b/projects/packages/my-jetpack/tests/php/assets/jetpack-mock-plugin.txt index 33cb376fc384..eec5325f6da1 100644 --- a/projects/packages/my-jetpack/tests/php/assets/jetpack-mock-plugin.txt +++ b/projects/packages/my-jetpack/tests/php/assets/jetpack-mock-plugin.txt @@ -22,6 +22,15 @@ class Jetpack { static $return_false = false; // Force return false in action methods. + // Whether the Premium Analytics dashboard replaces the Stats page. Named apart from the real + // plugin's own private static of the same purpose, so tests setting it can't be confused for + // reaching into that one. + static $mock_premium_analytics_enabled = false; + + public static function is_premium_analytics_enabled() { + return self::$mock_premium_analytics_enabled; + } + public static function is_module_active( $module_name ) { return in_array( $module_name, self::$active_modules ); } diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces b/projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces new file mode 100644 index 000000000000..bbec5d90ae86 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Publish the dashboard page slug, capability, and site timezone in Jetpack script data, so other Jetpack surfaces can link to the dashboard. diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces-plan-usage b/projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces-plan-usage new file mode 100644 index 000000000000..77bc3840af5d --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1780-premium-analytics-update-jetpack-surfaces-plan-usage @@ -0,0 +1,4 @@ +Significance: patch +Type: removed + +Plan usage: remove the upgrade link, which pointed at a Stats screen that no longer exists. diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 14d8cd7afb46..782e6b4fba17 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -195,6 +195,83 @@ private static function boot_shared_services() { // CSV report export pipeline (WOOA7S-1581): hooks rest_api_init, so it must // register on all requests. Self-gates on WooCommerce + Jetpack connection. Export::configure(); + + self::register_script_data(); + } + + /** + * Announce, to every script on every admin page, that this dashboard is the + * site's analytics UI. + * + * Jetpack surfaces outside this package link to analytics — the Jetpack + * dashboard's stats card, the My Jetpack Stats card, the newsletter widget, + * the block editor's email stats link. They build those URLs with + * `getAnalyticsUrl()`, which needs to know which analytics UI the site runs. + * Publishing the key here rather than having each caller ask Jetpack makes + * the package that owns the dashboard the single source of that answer: the + * key exists exactly where Premium Analytics is the dashboard. + * + * Registered from both init paths, so Simple gets it too. + * + * @return void + */ + private static function register_script_data() { + add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_script_data' ) ); + } + + /** + * Add the `analytics` key to Jetpack's admin script data. + * + * This filter runs on nearly every admin page load, so the payload stays to + * two strings, a bool, and one capability check. + * + * @param array $data The script data. + * @return array The script data with the analytics key added. + */ + public static function add_script_data( $data ) { + $data['analytics'] = array( + 'enabled' => true, + 'page_slug' => self::MENU_PAGE_SLUG, + 'can_view' => current_user_can( Capabilities::VIEW_ANALYTICS ), + 'timezone' => self::site_timezone(), + ); + + return $data; + } + + /** + * The site's timezone, for encoding date ranges into dashboard URLs. + * + * Prefers `timezone_string` over `gmt_offset`, matching the dashboard's own + * `getSiteTimezone()`. The distinction matters: analytics links always point + * at past dates, so they cross daylight-saving boundaries routinely, and a + * fixed offset applied to the far side of a transition shifts the day. + * + * @return string An IANA timezone name, or a `+HH:MM` UTC offset. + */ + private static function site_timezone() { + $timezone_string = get_option( 'timezone_string' ); + + if ( is_string( $timezone_string ) && $timezone_string !== '' ) { + return $timezone_string; + } + + return self::format_gmt_offset( (float) get_option( 'gmt_offset' ) ); + } + + /** + * Format a GMT offset in hours as `+HH:MM`. + * + * @param float $offset The offset in hours, e.g. 5.5 or -8. + * @return string The formatted offset. + */ + private static function format_gmt_offset( $offset ) { + $sign = $offset < 0 ? '-' : '+'; + $absolute = abs( $offset ); + $hours = (int) floor( $absolute ); + $minutes = (int) floor( ( $absolute - $hours ) * 60 + 1e-6 ); + + return sprintf( '%s%02d:%02d', $sign, $hours, $minutes ); } /** @@ -325,13 +402,22 @@ private static function register_admin_page() { add_action( 'jetpack-premium-analytics_init', array( static::class, 'ensure_script_data' ) ); } + /** + * The admin page slug the dashboard menu registers. + * + * The wp-build "wp-admin integrated" variant. Published in script data so + * `getAnalyticsUrl()` can route to the dashboard without any caller + * hard-coding the slug. + */ + const MENU_PAGE_SLUG = 'jetpack-premium-analytics-wp-admin'; + /** * Admin page slugs that render the Premium Analytics dashboard. * * Mirrors the slugs the wp-build interceptor renders (full-page and the * wp-admin integrated variant). */ - const DASHBOARD_PAGE_SLUGS = array( 'jetpack-premium-analytics', 'jetpack-premium-analytics-wp-admin' ); + const DASHBOARD_PAGE_SLUGS = array( 'jetpack-premium-analytics', self::MENU_PAGE_SLUG ); /** * Whether the current request is rendering a Premium Analytics dashboard page. @@ -388,7 +474,7 @@ public static function register_admin_menu() { esc_html( $menu_title ), esc_html( $menu_title ), Capabilities::VIEW_ANALYTICS, - 'jetpack-premium-analytics-wp-admin', + self::MENU_PAGE_SLUG, $render_callback, 'dashicons-chart-bar', 2 diff --git a/projects/packages/premium-analytics/tests/php/Analytics_Test.php b/projects/packages/premium-analytics/tests/php/Analytics_Test.php index 6dc6490ffeac..701635526243 100644 --- a/projects/packages/premium-analytics/tests/php/Analytics_Test.php +++ b/projects/packages/premium-analytics/tests/php/Analytics_Test.php @@ -419,6 +419,115 @@ public function test_register_admin_menu_uses_the_dashboard_capability() { $this->assertSame( Capabilities::VIEW_ANALYTICS, $menu_item[1] ?? null ); } + /** + * The `analytics` key's presence is what tells every Jetpack surface outside + * this package that this dashboard — not the legacy Stats page — is where + * analytics links should go. + */ + public function test_add_script_data_announces_the_dashboard() { + $data = Analytics::add_script_data( array() ); + + $this->assertTrue( $data['analytics']['enabled'] ); + $this->assertSame( Analytics::MENU_PAGE_SLUG, $data['analytics']['page_slug'] ); + } + + /** + * The published slug must be the slug the menu actually registers, or every + * link built from it 404s. + */ + public function test_add_script_data_publishes_the_registered_menu_slug() { + $menu_item = $this->register_admin_menu_without_build(); + $data = Analytics::add_script_data( array() ); + + $this->assertSame( $menu_item[2] ?? null, $data['analytics']['page_slug'] ); + } + + /** + * The filter is shared, so the key must be added without disturbing anything + * another consumer already put there. + */ + public function test_add_script_data_preserves_existing_data() { + $data = Analytics::add_script_data( + array( + 'site' => array( 'admin_url' => 'https://example.com/wp-admin/' ), + 'analytics' => array( 'stale' => true ), + ) + ); + + $this->assertSame( 'https://example.com/wp-admin/', $data['site']['admin_url'] ); + $this->assertArrayNotHasKey( 'stale', $data['analytics'] ); + } + + /** + * `can_view` answers the same question the menu's capability does, so a caller + * can hide a link rather than send someone to a screen they cannot open. + */ + public function test_add_script_data_reports_can_view_for_a_capable_user() { + Capabilities::register(); + add_filter( 'user_has_cap', array( $this, 'grant_manage_options' ) ); + + $data = Analytics::add_script_data( array() ); + + remove_filter( 'user_has_cap', array( $this, 'grant_manage_options' ) ); + $this->assertTrue( $data['analytics']['can_view'] ); + } + + public function test_add_script_data_reports_can_view_false_without_the_capability() { + Capabilities::register(); + + $data = Analytics::add_script_data( array() ); + + $this->assertFalse( $data['analytics']['can_view'] ); + } + + /** + * A named zone is preferred over a fixed offset: analytics links always point + * at past dates, so they cross daylight-saving boundaries routinely and a + * fixed offset would shift the day on the far side of a transition. + */ + public function test_add_script_data_prefers_the_timezone_string() { + update_option( 'timezone_string', 'America/New_York' ); + update_option( 'gmt_offset', 5.5 ); + + $data = Analytics::add_script_data( array() ); + + $this->assertSame( 'America/New_York', $data['analytics']['timezone'] ); + } + + /** + * Sites configured with a raw UTC offset have no zone name to publish, so the + * offset is formatted the way the dashboard's own timezone resolver does. + * + * @param mixed $offset The `gmt_offset` option value. + * @param string $expected The expected published timezone. + * @dataProvider provide_gmt_offsets + */ + #[DataProvider( 'provide_gmt_offsets' )] + public function test_add_script_data_falls_back_to_the_formatted_gmt_offset( $offset, $expected ) { + update_option( 'timezone_string', '' ); + update_option( 'gmt_offset', $offset ); + + $data = Analytics::add_script_data( array() ); + + $this->assertSame( $expected, $data['analytics']['timezone'] ); + } + + /** + * @return array + */ + public static function provide_gmt_offsets() { + return array( + 'UTC' => array( 0, '+00:00' ), + 'whole hours east' => array( 2, '+02:00' ), + 'whole hours west' => array( -8, '-08:00' ), + 'half hour east' => array( 5.5, '+05:30' ), + 'half hour west' => array( -3.5, '-03:30' ), + 'three quarter hour' => array( 5.75, '+05:45' ), + 'double digit hours' => array( 13, '+13:00' ), + 'string option' => array( '-5', '-05:00' ), + ); + } + /** * With no caller override the label comes from the package itself, so nobody has * to translate it before the textdomain can load. diff --git a/projects/packages/premium-analytics/widgets/plan-usage/__tests__/plan-usage.test.tsx b/projects/packages/premium-analytics/widgets/plan-usage/__tests__/plan-usage.test.tsx index f84417a3fc5e..f062987e92a8 100644 --- a/projects/packages/premium-analytics/widgets/plan-usage/__tests__/plan-usage.test.tsx +++ b/projects/packages/premium-analytics/widgets/plan-usage/__tests__/plan-usage.test.tsx @@ -42,8 +42,8 @@ describe( 'PlanUsageWidget', () => { queryClient.clear(); mockApiFetch.mockReset(); mockApiFetch.mockResolvedValue( PLAN_USAGE_RESPONSE ); - // The upgrade note builds its purchase URL from the script data wp-admin - // prints on the page. + // The widget reads the host guess from the script data wp-admin prints on + // the page, to suppress the over-limit warning on VIP sites. window.JetpackScriptData = { site: { admin_url: 'https://example.com/wp-admin/', @@ -70,17 +70,28 @@ describe( 'PlanUsageWidget', () => { expect( meter.max ).toBe( 10000 ); } ); - it( 'links the upgrade note to the Stats purchase screen for the connected site', async () => { + // The upgrade call to action pointed at the Stats tier-purchase screen, a + // Calypso route inside the CDN-served Odyssey bundle that this dashboard has + // no counterpart for. It was removed rather than left pointing at a route + // that no longer resolves. + it( 'renders no upgrade call to action', async () => { render( ); - const upgradeLink = await screen.findByRole( 'link', { name: 'Upgrade now' } ); - expect( upgradeLink ).toHaveAttribute( - 'href', - expect.stringContaining( - 'https://example.com/wp-admin/admin.php?page=stats#!/stats/purchase/123456789' - ) - ); - expect( screen.getByText( /Do you want to increase your views limit\?/ ) ).toBeInTheDocument(); + await expect( screen.findByText( '6,200 / 10,000 views' ) ).resolves.toBeInTheDocument(); + expect( screen.queryByRole( 'link', { name: 'Upgrade now' } ) ).not.toBeInTheDocument(); + expect( screen.queryByText( /increase your views limit/ ) ).not.toBeInTheDocument(); + expect( screen.queryByText( /page=stats/ ) ).not.toBeInTheDocument(); + } ); + + it( 'renders no upgrade call to action even when over the limit', async () => { + mockApiFetch.mockResolvedValue( { ...PLAN_USAGE_RESPONSE, over_limit_months: 2 } ); + + render( ); + + await expect( + screen.findByText( "You've surpassed your limit for two consecutive periods already." ) + ).resolves.toBeInTheDocument(); + expect( screen.queryByRole( 'link', { name: 'Upgrade now' } ) ).not.toBeInTheDocument(); } ); // The warning is driven solely by `over_limit_months`, independent of the @@ -184,7 +195,6 @@ describe( 'PlanUsageWidget', () => { screen.findByText( "Plan usage isn't available for your current plan." ) ).resolves.toBeInTheDocument(); expect( screen.queryByRole( 'progressbar' ) ).not.toBeInTheDocument(); - expect( screen.queryByText( /increase your views limit/ ) ).not.toBeInTheDocument(); } ); // A zero limit gives nothing to meter against (`max={0}` is degenerate), so @@ -200,15 +210,15 @@ describe( 'PlanUsageWidget', () => { expect( screen.queryByRole( 'progressbar' ) ).not.toBeInTheDocument(); } ); - it( 'omits the upgrade note when script data provides no purchase URL', async () => { - // Without wp-admin script data there is no purchase URL to link to. + // Storybook and any host that has not printed script data land here, so the + // meter must not depend on the global being present. + it( 'renders the meter when script data is absent', async () => { window.JetpackScriptData = undefined as unknown as typeof window.JetpackScriptData; render( ); await expect( screen.findByText( '6,200 / 10,000 views' ) ).resolves.toBeInTheDocument(); - expect( screen.queryByText( /increase your views limit/ ) ).not.toBeInTheDocument(); - expect( screen.queryByRole( 'link', { name: 'Upgrade now' } ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'progressbar' ) ).toBeInTheDocument(); } ); it( 'renders the error state with a retry action when the request fails', async () => { diff --git a/projects/packages/premium-analytics/widgets/plan-usage/render.tsx b/projects/packages/premium-analytics/widgets/plan-usage/render.tsx index 78a3b8781282..d62bc8df3ee1 100644 --- a/projects/packages/premium-analytics/widgets/plan-usage/render.tsx +++ b/projects/packages/premium-analytics/widgets/plan-usage/render.tsx @@ -9,10 +9,9 @@ import { WidgetState, type ReportParamsFieldAttributes, } from '@jetpack-premium-analytics/widgets-toolkit'; -import { createInterpolateElement } from '@wordpress/element'; import { __, _n, sprintf } from '@wordpress/i18n'; import { percent } from '@wordpress/icons'; -import { Link, Stack, Text } from '@jetpack-premium-analytics/externals'; +import { Stack, Text } from '@jetpack-premium-analytics/externals'; import clsx from 'clsx'; /** * Internal dependencies @@ -67,41 +66,27 @@ function overLimitMessage( overLimitMonths: number ): string { return __( "You've surpassed your limit the past month.", 'jetpack-premium-analytics-pkg' ); } -/** - * The Stats tier-upgrade purchase screen for this site — the same flow the - * Stats "Plan usage" section links to — returning to this dashboard after - * checkout. `undefined` where script data is absent (e.g. Storybook without a - * seeded `window.JetpackScriptData`). - * - * @return The purchase screen URL. - */ -function upgradeUrl(): string | undefined { - const site = getScriptData()?.site; - const blogId = site?.wpcom?.blog_id; - if ( ! site?.admin_url || ! blogId ) { - return undefined; - } - - const backTo = encodeURIComponent( 'admin.php?page=jetpack-premium-analytics-wp-admin' ); - return `${ site.admin_url }admin.php?page=stats#!/stats/purchase/${ blogId }?from=jetpack-premium-analytics&productType=commercial&redirect_uri=${ backTo }`; -} - /** * Presentational bar for the "Plan usage" widget, following the Stats "Plan * usage" section: a horizontal meter filled proportionally to the billable * views used against the plan's cycle limit, the figures and days-until-reset - * inside the bar, and the upgrade note (with the over-limit warning when - * applicable) below it when a purchase URL or warning is available. Renders + * inside the bar, and the over-limit warning below it when applicable. Renders * the populated state only — `WidgetState` owns loading, error, and * unavailable. * + * There is no upgrade call to action. It used to link to the Stats tier-purchase + * screen, which was a Calypso route inside the CDN-served Odyssey bundle rather + * than a page this repo hosts, so it left with that dashboard and has no + * counterpart here yet. Showing the meter without a way to act on it beats + * linking somewhere that no longer resolves; restoring the CTA is tracked + * separately. + * * @param {PlanUsageBarProps} props - The component props. * @return The rendered bar. */ function PlanUsageBar( { limit, usage, daysToReset, overLimitMonths }: PlanUsageBarProps ) { const usageValue = usage ?? 0; const isOverLimit = usageValue >= limit; - const upgradeHref = upgradeUrl(); return ( ) } - { ( !! overLimitMonths || upgradeHref ) && ( + { !! overLimitMonths && ( - { overLimitMonths ? ( - <> - { overLimitMessage( overLimitMonths ) }{ ' ' } - - ) : null } - { /* Without script data there is no purchase URL, and a Link with no - href renders styled but non-actionable — omit the sentence. */ } - { upgradeHref && - createInterpolateElement( - __( - 'Do you want to increase your views limit? Upgrade now', - 'jetpack-premium-analytics-pkg' - ), - { a: } - ) } + { overLimitMessage( overLimitMonths ) } ) } diff --git a/projects/packages/premium-analytics/widgets/plan-usage/stories/plan-usage-widget.stories.tsx b/projects/packages/premium-analytics/widgets/plan-usage/stories/plan-usage-widget.stories.tsx index 123434c2f605..096e7d7c660b 100644 --- a/projects/packages/premium-analytics/widgets/plan-usage/stories/plan-usage-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/plan-usage/stories/plan-usage-widget.stories.tsx @@ -35,15 +35,13 @@ import type { ComponentProps, ComponentType } from 'react'; registerReportMocks(); -// The upgrade note builds its purchase URL from `window.JetpackScriptData`, -// which only wp-admin provides; seed the fields it reads so the "Upgrade now" -// link resolves in Storybook too. +// The widget reads the host guess from `window.JetpackScriptData`, which only +// wp-admin provides. Seed the `site` object so the VIP story below can set and +// clear `site.host` on it. window.JetpackScriptData = { ...window.JetpackScriptData, site: { ...window.JetpackScriptData?.site, - admin_url: 'https://example.com/wp-admin/', - wpcom: { blog_id: 123456789 }, }, } as typeof window.JetpackScriptData; diff --git a/projects/plugins/jetpack/_inc/client/at-a-glance/stats/chart-bar-range.js b/projects/plugins/jetpack/_inc/client/at-a-glance/stats/chart-bar-range.js new file mode 100644 index 000000000000..c8b125ea24ae --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/at-a-glance/stats/chart-bar-range.js @@ -0,0 +1,57 @@ +/** + * The span of calendar days a stats chart bar stands for. + * + * The chart's Days / Weeks / Months tabs each plot bars keyed on the first day + * of the period, so the bar's own date alone doesn't say how much time it + * covers. Clicking a "Weeks" bar should open that whole week, not just its + * Monday — the old Stats link ignored the tab and always deep-linked to a single + * day, which is part of why it landed somewhere that didn't match the bar. + * + * Bar dates arrive from the Stats API as calendar days and are encoded as UTC + * midnight (see `statsChart`), so every boundary here is computed and read in + * UTC. Reading local components instead would shift the day for any site west of + * UTC. + * + * @param {string} isoDate - The bar's date, as a UTC-midnight ISO string. + * @param {string} unit - The active chart tab: 'day', 'week', or 'month'. + * + * @return {?{from: string, to: string}} The inclusive range as `YYYY-MM-DD` days, or undefined for an unusable date. + */ +export function chartBarRange( isoDate, unit ) { + const parsed = new Date( isoDate ); + if ( isNaN( parsed.getTime() ) ) { + return undefined; + } + + const year = parsed.getUTCFullYear(); + const month = parsed.getUTCMonth(); + const day = parsed.getUTCDate(); + + let from; + let to; + + if ( 'week' === unit ) { + from = new Date( Date.UTC( year, month, day ) ); + to = new Date( Date.UTC( year, month, day + 6 ) ); + } else if ( 'month' === unit ) { + from = new Date( Date.UTC( year, month, 1 ) ); + // Day zero of the next month is the last day of this one. + to = new Date( Date.UTC( year, month + 1, 0 ) ); + } else { + from = new Date( Date.UTC( year, month, day ) ); + to = from; + } + + return { from: toCalendarDay( from ), to: toCalendarDay( to ) }; +} + +/** + * Render a UTC instant as its `YYYY-MM-DD` calendar day. + * + * @param {Date} date - The date to render. + * + * @return {string} The calendar day. + */ +function toCalendarDay( date ) { + return date.toISOString().slice( 0, 10 ); +} diff --git a/projects/plugins/jetpack/_inc/client/at-a-glance/stats/dash-stats-bottom.jsx b/projects/plugins/jetpack/_inc/client/at-a-glance/stats/dash-stats-bottom.jsx index b61db678062b..628aff09003a 100644 --- a/projects/plugins/jetpack/_inc/client/at-a-glance/stats/dash-stats-bottom.jsx +++ b/projects/plugins/jetpack/_inc/client/at-a-glance/stats/dash-stats-bottom.jsx @@ -1,4 +1,5 @@ import { getRedirectUrl } from '@automattic/jetpack-components'; +import { getAnalyticsUrl } from '@automattic/jetpack-script-data'; import { formatNumber } from '@automattic/number-formatters'; import { dateI18n } from '@wordpress/date'; import { createInterpolateElement } from '@wordpress/element'; @@ -47,6 +48,9 @@ class DashStatsBottom extends Component { render() { const s = this.statsBottom()[ 0 ]; + // Null when the current user cannot open the analytics dashboard — the + // summary figures still render, just without the button. + const analyticsUrl = getAnalyticsUrl( { view: 'dashboard' } ); return (
@@ -98,15 +102,12 @@ class DashStatsBottom extends Component {
{ // Only show link for non-atomic Jetpack sites. - createInterpolateElement( __( '', 'jetpack' ), { - button: ( - ', 'jetpack' ), { + button: ( +