| 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 |
|