PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / trunk
Jetpack – WP Security, Backup, Speed, & Growth vtrunk
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / _inc / shared / analytics-url.ts
jetpack / _inc / shared Last commit date
test 1 week ago analytics-url.ts 1 week ago
analytics-url.ts
221 lines
1 /**
2 * Links into the site's analytics dashboard.
3 *
4 * Imported by relative path from three bundles built by two webpack configs, so
5 * it lives here rather than in a shared package. My Jetpack deliberately does
6 * not use it — its Stats card gets the destination as the product's
7 * `manage_url`, resolved server-side.
8 */
9
10 import { getAdminUrl, getScriptData } from '@automattic/jetpack-script-data';
11 import { tz, TZDateMini } from '@date-fns/tz';
12 import { endOfDay, format, startOfDay } from 'date-fns';
13
14 /**
15 * Published by Premium Analytics only where it is the site's analytics UI, so
16 * its presence is the branch signal.
17 */
18 interface AnalyticsScriptData {
19 enabled: boolean;
20 page_slug: string;
21
22 /**
23 * False means hide analytics links rather than lead to a capability error.
24 */
25 can_view: boolean;
26
27 /**
28 * An IANA name (`America/New_York`), or a fixed UTC offset (`+05:30`).
29 */
30 timezone: string;
31 }
32
33 /*
34 * Declared here rather than in the script-data package: the key is published by
35 * Premium Analytics and read only by this file.
36 */
37 declare module '@automattic/jetpack-script-data' {
38 interface JetpackScriptData {
39 analytics?: AnalyticsScriptData;
40 }
41 }
42
43 // Matches the dashboard's own `dateToISOStringWithTZ`.
44 const ISO_WITH_OFFSET = "yyyy-MM-dd'T'HH:mm:ss.SSSxxx";
45
46 /** A range of whole calendar days in the site's timezone, as `YYYY-MM-DD`. */
47 interface AnalyticsDateRange {
48 from: string;
49 to: string;
50 }
51
52 type AnalyticsDashboardSection = 'traffic' | 'insights' | 'subscribers' | 'store';
53 type AnalyticsPostSection = 'traffic' | 'email-opens' | 'email-clicks';
54
55 /**
56 * Where to go, not how the URL is spelled.
57 *
58 * Only the two views Jetpack links to are modelled; the dashboard also serves
59 * `/reports/$report` and `/video/$videoId`. Deliberately absent: the site and
60 * blog identifiers, resolved from script data so callers need not thread them
61 * through props.
62 */
63 export type AnalyticsView =
64 | { view: 'dashboard'; section?: AnalyticsDashboardSection; range?: AnalyticsDateRange }
65 | { view: 'post'; id: number; section?: AnalyticsPostSection; range?: AnalyticsDateRange };
66
67 /**
68 * `?section=` takes the slug — the segment after the namespace, so
69 * `analytics/traffic` is registered and `traffic` reaches the URL. A list, not a
70 * map, because the two coincide; it exists to reject unknown values arriving
71 * from untyped JS callers.
72 */
73 const DASHBOARD_SECTIONS: readonly string[] = [ 'traffic', 'insights', 'subscribers', 'store' ];
74
75 /** Callers say `traffic`; the tab layout registry calls it `post-traffic`. */
76 const POST_SECTIONS: Record< AnalyticsPostSection, string > = {
77 traffic: 'post-traffic',
78 'email-opens': 'email-opens',
79 'email-clicks': 'email-clicks',
80 };
81
82 /**
83 * Reads the key Premium Analytics publishes.
84 *
85 * @return The analytics script data, or undefined where the dashboard is not the analytics UI.
86 */
87 function getAnalyticsScriptData(): AnalyticsScriptData | undefined {
88 const analytics = getScriptData()?.analytics;
89
90 return analytics?.enabled ? analytics : undefined;
91 }
92
93 /**
94 * Whether to route analytics links to the dashboard.
95 *
96 * The Stats page still renders alongside the dashboard for now, so this is
97 * about which one a link should point at, not about one having gone away.
98 *
99 * Distinct from a null URL: false means "keep your existing Stats link", null
100 * means the dashboard is the analytics UI here but this user cannot open it.
101 * Hiding the control beats falling back, because the dashboard capability maps
102 * to `manage_options` or `view_stats` — a user who fails it cannot open the
103 * Stats page either.
104 *
105 * @return Whether the dashboard is the analytics UI here.
106 */
107 export function hasAnalyticsDashboard(): boolean {
108 return getAnalyticsScriptData() !== undefined;
109 }
110
111 /**
112 * Encodes a calendar day as the offset-bearing timestamp the dashboard writes
113 * itself. A bare `YYYY-MM-DD` would be parsed as UTC midnight and land a day
114 * early west of UTC.
115 *
116 * `TZDateMini`'s parts constructor reads the wall clock *in* the target zone, so
117 * the offset is the one in effect on that day — which differs between the two
118 * boundaries across a daylight-saving transition.
119 *
120 * @param day - The calendar day, `YYYY-MM-DD` in the site's timezone.
121 * @param boundary - Which end of the day to encode.
122 * @param timezone - An IANA name or a fixed UTC offset.
123 * @return The encoded timestamp, or undefined when the day cannot be encoded.
124 */
125 function encodeDay( day: string, boundary: 'start' | 'end', timezone: string ): string | undefined {
126 const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec( day );
127 if ( ! parts ) {
128 return undefined;
129 }
130
131 const [ , year, month, date ] = parts;
132
133 try {
134 const midnight = new TZDateMini(
135 Number( year ),
136 Number( month ) - 1,
137 Number( date ),
138 timezone
139 );
140 const at = boundary === 'start' ? startOfDay( midnight ) : endOfDay( midnight );
141
142 return format( at, ISO_WITH_OFFSET, { in: tz( timezone ) } );
143 } catch {
144 // An unusable zone throws a RangeError out of Intl.
145 return undefined;
146 }
147 }
148
149 /**
150 * A half-applied range would silently widen the window, so an unusable one is
151 * dropped whole. `interval`, `preset` and the comparison params are left off:
152 * the route seeds an interval itself, and omitting `preset` keeps the range
153 * custom rather than forcing a comparison nobody asked for.
154 *
155 * @param range - The requested range.
156 * @param timezone - The site timezone.
157 * @return The search params to merge, empty when the range cannot be encoded.
158 */
159 function rangeParams( range: AnalyticsDateRange, timezone: string ): Record< string, string > {
160 const from = encodeDay( range.from, 'start', timezone );
161 const to = encodeDay( range.to, 'end', timezone );
162
163 return from && to ? { from, to } : {};
164 }
165
166 /**
167 * An unknown section resolves to the route's default tab anyway, so it is
168 * dropped rather than left dead in a shareable URL.
169 *
170 * @param view - The requested view.
171 * @return The search params to merge.
172 */
173 function analyticsSection( view: AnalyticsView ): Record< string, string > {
174 const section =
175 view.view === 'dashboard'
176 ? view.section && DASHBOARD_SECTIONS.includes( view.section ) && view.section
177 : view.section && POST_SECTIONS[ view.section ];
178
179 return section ? { section } : {};
180 }
181
182 /**
183 * Builds a URL into the Premium Analytics dashboard.
184 *
185 * `@wordpress/boot` keeps the client-side router's whole path-and-search in one
186 * `p` query param, so the internal path is built first and then encoded into it.
187 *
188 * @param view - The requested view.
189 * @return The URL, or null when the dashboard is not the analytics UI, the user cannot open it, or the view has no route.
190 *
191 * @example
192 * hasAnalyticsDashboard()
193 * ? getAnalyticsUrl( { view: 'dashboard', section: 'subscribers' } )
194 * : legacyStatsUrl;
195 */
196 export function getAnalyticsUrl( view: AnalyticsView ): string | null {
197 const analytics = getAnalyticsScriptData();
198
199 if ( ! analytics || ! analytics.can_view ) {
200 return null;
201 }
202
203 const path = view.view === 'dashboard' ? '/' : view.id > 0 && `/post/${ view.id }`;
204 if ( ! path ) {
205 return null;
206 }
207
208 const search = new URLSearchParams( {
209 ...analyticsSection( view ),
210 ...( view.range ? rangeParams( view.range, analytics.timezone ) : {} ),
211 } );
212 const query = search.toString();
213
214 const page = new URLSearchParams( {
215 page: analytics.page_slug,
216 p: query ? `${ path }?${ query }` : path,
217 } );
218
219 return getAdminUrl( `admin.php?${ page }` );
220 }
221