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 |