PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 All 502 releases
jetpack / _inc / shared / analytics-url.ts

analytics-url.ts in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at _inc/shared/analytics-url.ts

221 lines 7.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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