@raketa-cloud/admin 1.8.18 → 1.8.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/application.css +288 -11
  2. package/package.json +8 -6
  3. package/src/cloud/Resources.js +16 -4
  4. package/src/handlers/resources/ReportsAPIHandler.js +27 -0
  5. package/src/handlers/resources/ResourcesHandler.js +16 -0
  6. package/src/handlers/resources/TranslationsHandler.js +1 -1
  7. package/src/helpers/reports/cwv.js +217 -0
  8. package/src/helpers/reports/googleAuth.js +149 -0
  9. package/src/helpers/reports/quick-insights.js +294 -0
  10. package/src/helpers/reports/reportsUtils.js +181 -0
  11. package/src/index.js +8 -0
  12. package/src/localCache.js +112 -0
  13. package/src/report/analytics.js +107 -0
  14. package/src/report/reports.js +390 -0
  15. package/src/routeAssets.js +74 -0
  16. package/src/routeResourceModule.js +24 -0
  17. package/src/routeStandard.js +7 -1
  18. package/src/styles/application.css +1 -1
  19. package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
  20. package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
  21. package/src/tests/handlers/categories/update_defaults.test.js +35 -0
  22. package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
  23. package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
  24. package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
  25. package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
  26. package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
  27. package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
  28. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
  29. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
  30. package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +213 -0
  31. package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
  32. package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
  33. package/src/tests/helpers/HTMLBuilder.test.js +51 -0
  34. package/src/tests/helpers/historyUtils.branches.test.js +36 -0
  35. package/src/tests/helpers/reports/cwv.test.js +239 -0
  36. package/src/tests/helpers/reports/quick-insights.test.js +593 -0
  37. package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
  38. package/src/tests/helpers/utils.test.js +37 -0
  39. package/src/tests/helpers/view_helpers.test.js +87 -0
  40. package/src/tests/index.test.js +74 -0
  41. package/src/tests/localCache.test.js +236 -0
  42. package/src/tests/report/analytics.test.js +230 -0
  43. package/src/tests/report/reports.test.js +831 -0
  44. package/src/tests/routeAssets.test.js +120 -0
  45. package/src/tests/routeResourceModule.test.js +99 -2
  46. package/src/tests/routeStandard.test.js +3 -3
  47. package/src/tests/support/fakeDom.js +164 -0
  48. package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
  49. package/src/views/resources/_shared/_quick_insights.ejs +64 -0
  50. package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
  51. package/src/views/resources/_shared/_title.ejs +16 -12
  52. package/src/views/resources/_shared/_toolbar.ejs +11 -21
  53. package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
  54. package/src/views/resources/_shared/_translations_strip.ejs +13 -25
  55. package/src/views/resources/_shared/_vital_meter.ejs +54 -0
  56. package/src/views/resources/show.ejs +88 -98
  57. package/src/views/resources/translations/create_form.ejs +31 -1
@@ -0,0 +1,217 @@
1
+ import localCache from "../../localCache";
2
+
3
+ // Same window as the GA cache (see quick-insights.js): one upstream request per
4
+ // page per 3 hours.
5
+ const CWV_CACHE_TTL = 1000 * 60 * 60 * 3;
6
+
7
+ // CrUX will not take a service-account bearer token: OAuth credentials get a
8
+ // blanket 400 INVALID_ARGUMENT whatever the body. It authorizes with an API key
9
+ // only (public dataset, no per-resource ACL) — the exact opposite of the
10
+ // Analytics Data API, which rejects keys and needs the service account.
11
+ export const cruxApiKeyConfigured = (ga = {}) =>
12
+ Boolean(ga.apiKey) && ga.apiKey !== "YOUR-GOOGLE-API-KEY";
13
+
14
+ const ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
15
+
16
+ const THRESHOLDS = {
17
+ largest_contentful_paint: [2500, 4000],
18
+ interaction_to_next_paint: [200, 500],
19
+ cumulative_layout_shift: [0.1, 0.25],
20
+ first_contentful_paint: [1800, 3000],
21
+ experimental_time_to_first_byte: [800, 1800],
22
+ };
23
+
24
+ const METRICS = [
25
+ {
26
+ name: "largest_contentful_paint",
27
+ key: "lcp",
28
+ label: "LCP",
29
+ title: "Largest Contentful Paint",
30
+ unit: "ms",
31
+ },
32
+ {
33
+ name: "interaction_to_next_paint",
34
+ key: "inp",
35
+ label: "INP",
36
+ title: "Interaction to Next Paint",
37
+ unit: "ms",
38
+ },
39
+ {
40
+ name: "cumulative_layout_shift",
41
+ key: "cls",
42
+ label: "CLS",
43
+ title: "Cumulative Layout Shift",
44
+ unit: "",
45
+ },
46
+ {
47
+ name: "first_contentful_paint",
48
+ key: "fcp",
49
+ label: "FCP",
50
+ title: "First Contentful Paint",
51
+ unit: "ms",
52
+ },
53
+ {
54
+ name: "experimental_time_to_first_byte",
55
+ key: "ttfb",
56
+ label: "TTFB",
57
+ title: "Time to First Byte",
58
+ unit: "ms",
59
+ },
60
+ ];
61
+
62
+ export const CWV_METRICS = METRICS.map((meta) => {
63
+ const [good, needsImprovement] = THRESHOLDS[meta.name];
64
+
65
+ return {
66
+ key: meta.key,
67
+ label: meta.label,
68
+ title: meta.title,
69
+ unit: meta.unit,
70
+ thresholds: { good, poor: needsImprovement },
71
+ };
72
+ });
73
+
74
+ function rate(name, p75) {
75
+ const [good, needsImprovement] = THRESHOLDS[name];
76
+ if (p75 <= good) return "good";
77
+ if (p75 <= needsImprovement) return "needs-improvement";
78
+ return "poor";
79
+ }
80
+
81
+ async function query(target, ga, formFactor) {
82
+ const body = JSON.stringify({
83
+ ...target,
84
+ metrics: METRICS.map((m) => m.name),
85
+ ...(formFactor ? { formFactor } : {}),
86
+ });
87
+
88
+ const response = await fetch(
89
+ `${ENDPOINT}?key=${encodeURIComponent(ga.apiKey)}`,
90
+ {
91
+ method: "POST",
92
+ headers: { "content-type": "application/json" },
93
+ body,
94
+ },
95
+ );
96
+
97
+ if (response.status === 404) return null;
98
+
99
+ if (!response.ok) {
100
+ const body = await response.text();
101
+ throw new Error(`CrUX API ${response.status}: ${body.slice(0, 200)}`);
102
+ }
103
+
104
+ const json = await response.json();
105
+
106
+ return json.record ?? null;
107
+ }
108
+
109
+ const CORE_VITALS = ["lcp", "inp", "cls"];
110
+
111
+ function shape(record, scope) {
112
+ const metrics = [];
113
+
114
+ for (const meta of METRICS) {
115
+ const metric = record.metrics?.[meta.name];
116
+ if (!metric) continue;
117
+
118
+ const p75 = Number(metric.percentiles?.p75 ?? 0);
119
+ const bins = metric.histogram ?? [];
120
+ const [good, needsImprovement] = THRESHOLDS[meta.name];
121
+
122
+ metrics.push({
123
+ key: meta.key,
124
+ label: meta.label,
125
+ title: meta.title,
126
+ unit: meta.unit,
127
+ p75,
128
+ rating: rate(meta.name, p75),
129
+
130
+ thresholds: { good, poor: needsImprovement },
131
+
132
+ distribution: {
133
+ good: Number(bins[0]?.density ?? 0),
134
+ needsImprovement: Number(bins[1]?.density ?? 0),
135
+ poor: Number(bins[2]?.density ?? 0),
136
+ },
137
+ });
138
+ }
139
+
140
+ const core = metrics.filter((m) => CORE_VITALS.includes(m.key));
141
+ let assessment = null;
142
+ if (core.length) {
143
+ if (core.some((m) => m.rating === "poor")) {
144
+ assessment = "poor";
145
+ } else if (core.some((m) => m.rating === "needs-improvement")) {
146
+ assessment = "needs-improvement";
147
+ } else {
148
+ assessment = "good";
149
+ }
150
+ }
151
+
152
+ return {
153
+ scope,
154
+ metrics,
155
+ assessment,
156
+ collectionPeriod: record.collectionPeriod ?? null,
157
+ };
158
+ }
159
+
160
+ // Report for a page CrUX has no field data for: the full card set with no
161
+ // measurements, so the gauges render their bands and say "no data" rather than
162
+ // showing a made-up p75. `rating` stays null — 0ms is an absence, not a pass.
163
+ function emptyReport() {
164
+ return {
165
+ scope: "none",
166
+ noData: true,
167
+ assessment: null,
168
+ collectionPeriod: null,
169
+ metrics: CWV_METRICS.map((meta) => ({
170
+ ...meta,
171
+ p75: 0,
172
+ rating: null,
173
+ distribution: { good: 0, needsImprovement: 0, poor: 0 },
174
+ })),
175
+ };
176
+ }
177
+
178
+ const DEFAULT_FORM_FACTOR = "PHONE";
179
+
180
+ // cacheKey identifies the resource behind pageUrl (see reportCacheKey in
181
+ // reportsUtils) so one resource gets one entry; falls back to the URL.
182
+ export async function pageWebVitals(
183
+ pageUrl,
184
+ ga,
185
+ { formFactor = DEFAULT_FORM_FACTOR, cacheKey } = {},
186
+ ) {
187
+ if (!cruxApiKeyConfigured(ga)) {
188
+ throw new Error(
189
+ "CrUX API key is not set. Set GOOGLE_API_KEY in the environment (surfaced via config.ga.apiKey) — CrUX does not accept the service account.",
190
+ );
191
+ }
192
+ if (!pageUrl) {
193
+ throw new Error("No public URL for this resource.");
194
+ }
195
+
196
+ const origin = new URL(pageUrl).origin;
197
+ const key = `crux:${formFactor ?? "all"}:${cacheKey || pageUrl}`;
198
+
199
+ return localCache.fetch(
200
+ key,
201
+ async () => {
202
+ const urlRecord = await query({ url: pageUrl }, ga, formFactor);
203
+
204
+ if (urlRecord) return shape(urlRecord, "url");
205
+
206
+ const originRecord = await query({ origin }, ga, formFactor);
207
+
208
+ if (originRecord) return shape(originRecord, "origin");
209
+
210
+ // CrUX 404s (both URL- and origin-level) when a site hasn't met the
211
+ // traffic threshold — common on preview/staging domains. Return the card
212
+ // set unmeasured; `noData` tells the view to say so.
213
+ return emptyReport();
214
+ },
215
+ { ttl: CWV_CACHE_TTL },
216
+ );
217
+ }
@@ -0,0 +1,149 @@
1
+ // Service-account auth for the GA4 Data API (the Insights tab).
2
+ //
3
+ // GA is per-property authorized: it rejects API keys and needs a principal. The
4
+ // key file at config.ga.serviceAccountPath (GOOGLE_APPLICATION_CREDENTIALS)
5
+ // provides one with no human in the loop — sign a JWT with it, swap that for an
6
+ // access token. Nothing is persisted, so a restart costs one token exchange.
7
+ //
8
+ // The key's client_email needs Viewer access on the GA property.
9
+ import { createSign } from "node:crypto";
10
+ import { readFileSync } from "node:fs";
11
+
12
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
13
+ const JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
14
+ const SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
15
+
16
+ // Assertions are valid for an hour; ask for the same and refresh a minute early
17
+ // so a token can't expire mid-flight.
18
+ const TOKEN_LIFETIME_S = 3600;
19
+ const EXPIRY_SKEW_MS = 60 * 1000;
20
+
21
+ // Parsed key files by path, access tokens by client_email, plus the in-flight
22
+ // token exchange per client_email (see accessToken).
23
+ const keyCache = new Map();
24
+ const tokenCache = new Map();
25
+ const pendingTokens = new Map();
26
+
27
+ // True when a key path is configured — gates the Insights tab, so an
28
+ // unconfigured admin sees no GA UI at all.
29
+ export const serviceAccountConfigured = (ga = {}) =>
30
+ Boolean(ga.serviceAccountPath);
31
+
32
+ function serviceAccountKey(ga = {}) {
33
+ const path = ga.serviceAccountPath;
34
+
35
+ if (!path) {
36
+ throw new Error(
37
+ "Google service-account key is not set. Point GOOGLE_APPLICATION_CREDENTIALS at the key file (surfaced via config.ga.serviceAccountPath).",
38
+ );
39
+ }
40
+
41
+ if (keyCache.has(path)) return keyCache.get(path);
42
+
43
+ let key;
44
+ try {
45
+ key = JSON.parse(readFileSync(path, "utf8"));
46
+ } catch (err) {
47
+ throw new Error(
48
+ `Could not read the service-account key at ${path}: ${err.message}`,
49
+ );
50
+ }
51
+
52
+ if (!key.client_email || !key.private_key) {
53
+ throw new Error(
54
+ `The service-account key at ${path} has no client_email / private_key.`,
55
+ );
56
+ }
57
+
58
+ keyCache.set(path, key);
59
+ return key;
60
+ }
61
+
62
+ const base64url = (input) => Buffer.from(input).toString("base64url");
63
+
64
+ // Self-signed JWT asserting "this service account, for this scope".
65
+ function signAssertion(key) {
66
+ const issuedAt = Math.floor(Date.now() / 1000);
67
+
68
+ const header = base64url(
69
+ JSON.stringify({ alg: "RS256", typ: "JWT", kid: key.private_key_id }),
70
+ );
71
+ const claims = base64url(
72
+ JSON.stringify({
73
+ iss: key.client_email,
74
+ scope: SCOPE,
75
+ aud: key.token_uri || TOKEN_ENDPOINT,
76
+ iat: issuedAt,
77
+ exp: issuedAt + TOKEN_LIFETIME_S,
78
+ }),
79
+ );
80
+ const signature = createSign("RSA-SHA256")
81
+ .update(`${header}.${claims}`)
82
+ .sign(key.private_key, "base64url");
83
+
84
+ return `${header}.${claims}.${signature}`;
85
+ }
86
+
87
+ // Bearer token for the Data API, cached until shortly before it expires.
88
+ //
89
+ // Reports fire several GA calls at once, so on a cold (or just-expired) cache
90
+ // they would each mint their own token. The in-flight exchange is shared: the
91
+ // first caller starts it, the rest await the same promise.
92
+ export async function accessToken(ga) {
93
+ const key = serviceAccountKey(ga);
94
+
95
+ const cached = tokenCache.get(key.client_email);
96
+ if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
97
+
98
+ const pending = pendingTokens.get(key.client_email);
99
+ if (pending) return pending;
100
+
101
+ const exchange = requestToken(key).finally(() => {
102
+ pendingTokens.delete(key.client_email);
103
+ });
104
+
105
+ pendingTokens.set(key.client_email, exchange);
106
+
107
+ return exchange;
108
+ }
109
+
110
+ async function requestToken(key) {
111
+ const response = await fetch(key.token_uri || TOKEN_ENDPOINT, {
112
+ method: "POST",
113
+ headers: { "content-type": "application/x-www-form-urlencoded" },
114
+ body: new URLSearchParams({
115
+ grant_type: JWT_GRANT,
116
+ assertion: signAssertion(key),
117
+ }),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ const body = await response.text();
122
+ throw new Error(
123
+ `Google token request failed (${response.status}): ${body.slice(0, 200)}`,
124
+ );
125
+ }
126
+
127
+ const json = await response.json();
128
+
129
+ if (!json.access_token) {
130
+ throw new Error("Google token response contained no access_token.");
131
+ }
132
+
133
+ tokenCache.set(key.client_email, {
134
+ accessToken: json.access_token,
135
+ expiresAt:
136
+ Date.now() +
137
+ (json.expires_in ?? TOKEN_LIFETIME_S) * 1000 -
138
+ EXPIRY_SKEW_MS,
139
+ });
140
+
141
+ return json.access_token;
142
+ }
143
+
144
+ // Test / credential-rotation helper.
145
+ export function clearAuthCache() {
146
+ keyCache.clear();
147
+ tokenCache.clear();
148
+ pendingTokens.clear();
149
+ }
@@ -0,0 +1,294 @@
1
+ // GA4 Data API wrapper (admin-side).
2
+ // Quick Insights for a single page path: summary KPIs with period-over-period
3
+ // trend plus a daily page-views series, both over the last 30 days.
4
+ //
5
+ // `ga` below is the whole config.ga object — property id plus the key path the
6
+ // bearer token is minted from (see ./googleAuth).
7
+ import localCache from "../../localCache";
8
+ import { accessToken } from "./googleAuth";
9
+
10
+ // GA responses are large and change slowly — cache each bundle for 3 hours.
11
+ const GA_CACHE_TTL = 1000 * 60 * 60 * 3;
12
+
13
+ const GA_ENDPOINT = "https://analyticsdata.googleapis.com/v1beta";
14
+
15
+ function propertyId(ga = {}) {
16
+ const id = ga.propertyId;
17
+ if (!id || id === "YOUR-GA4-PROPERTY-ID") {
18
+ throw new Error(
19
+ "GA property id is not set. Set GA_PROPERTY_ID in the environment (surfaced via config.ga.propertyId).",
20
+ );
21
+ }
22
+ return id;
23
+ }
24
+
25
+ // One POST to the Data API. `method` is the endpoint suffix — runReport for a
26
+ // single report, batchRunReports for several in one round trip.
27
+ async function post(method, body, ga) {
28
+ const response = await fetch(
29
+ `${GA_ENDPOINT}/properties/${propertyId(ga)}:${method}`,
30
+ {
31
+ method: "POST",
32
+ headers: {
33
+ "content-type": "application/json",
34
+ authorization: `Bearer ${await accessToken(ga)}`,
35
+ },
36
+ body: JSON.stringify(body),
37
+ },
38
+ );
39
+
40
+ if (!response.ok) {
41
+ const text = await response.text();
42
+ throw new Error(`GA Data API ${response.status}: ${text.slice(0, 200)}`);
43
+ }
44
+
45
+ return response.json();
46
+ }
47
+
48
+ const runReport = (body, ga) => post("runReport", body, ga);
49
+
50
+ // Several reports, one HTTP request. The property is in the URL, so the
51
+ // individual requests must not repeat it.
52
+ async function batchRunReports(requests, ga) {
53
+ const json = await post("batchRunReports", { requests }, ga);
54
+ return json.reports ?? [];
55
+ }
56
+
57
+ // pagePath EXACT-match filter. Returns undefined when no pagePath is given so
58
+ // the same run* helpers double as site-wide (property-level) reports.
59
+ function pageFilter(pagePath) {
60
+ if (!pagePath) return undefined;
61
+ return {
62
+ filter: {
63
+ fieldName: "pagePath",
64
+ stringFilter: { matchType: "EXACT", value: pagePath },
65
+ },
66
+ };
67
+ }
68
+
69
+ const num = (v) => Number(v ?? 0);
70
+
71
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
72
+
73
+ // "YYYYMMDD" -> "YYYY-MM-DD"
74
+ function fmtDate(d) {
75
+ return d && d.length === 8
76
+ ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
77
+ : d;
78
+ }
79
+
80
+ // Every day in [startDate,endDate] — used to backfill days GA omits (it
81
+ // returns no row for zero-activity days, leaving gaps in the chart).
82
+ function expectedDays(startDate, endDate) {
83
+ if (!ISO_DATE.test(startDate ?? "") || !ISO_DATE.test(endDate ?? "")) {
84
+ return null;
85
+ }
86
+ const end = new Date(`${endDate}T00:00:00Z`);
87
+ const out = [];
88
+ for (
89
+ const d = new Date(`${startDate}T00:00:00Z`);
90
+ d <= end;
91
+ d.setUTCDate(d.getUTCDate() + 1)
92
+ ) {
93
+ out.push(d.toISOString().slice(0, 10));
94
+ }
95
+ return out;
96
+ }
97
+
98
+ // Given absolute ISO dates, the immediately-preceding period of equal length.
99
+ function previousRange(startDate, endDate) {
100
+ const s = new Date(`${startDate}T00:00:00Z`);
101
+ const e = new Date(`${endDate}T00:00:00Z`);
102
+ const days = Math.round((e - s) / 86400000) + 1;
103
+ const prevEnd = new Date(s.getTime() - 86400000);
104
+ const prevStart = new Date(prevEnd.getTime() - (days - 1) * 86400000);
105
+ const iso = (d) => d.toISOString().slice(0, 10);
106
+ return { startDate: iso(prevStart), endDate: iso(prevEnd) };
107
+ }
108
+
109
+ // % change current vs previous. null when there is nothing to divide by — a
110
+ // zero baseline has no percentage, so every metric ships its previous value
111
+ // too and the page can tell "brand new" from "never measured".
112
+ function trend(cur, prev) {
113
+ if (!prev) return null;
114
+ return ((cur - prev) / prev) * 100;
115
+ }
116
+
117
+ // Names for the two periods a summary compares. runReport takes up to four
118
+ // date ranges at once, so both periods come back from a single request; GA
119
+ // tags each row with a synthetic "dateRange" dimension carrying these names.
120
+ const CURRENT_RANGE = "current";
121
+ const PREVIOUS_RANGE = "previous";
122
+ const RANGE_DIMENSION = "dateRange";
123
+
124
+ const summaryRequest = ({ pagePath, startDate, endDate }) => ({
125
+ metrics: [
126
+ { name: "screenPageViews" },
127
+ { name: "activeUsers" },
128
+ { name: "userEngagementDuration" },
129
+ { name: "engagementRate" },
130
+ { name: "sessions" },
131
+ ],
132
+ dateRanges: [
133
+ { startDate, endDate, name: CURRENT_RANGE },
134
+ { ...previousRange(startDate, endDate), name: PREVIOUS_RANGE },
135
+ ],
136
+ dimensionFilter: pageFilter(pagePath),
137
+ });
138
+
139
+ function parseSummary(response) {
140
+ // Only the synthetic dimension is asked for, so it sits first — but read its
141
+ // position off the headers rather than assume it.
142
+ const headers = response.dimensionHeaders ?? [];
143
+ const rangeAt = Math.max(
144
+ headers.findIndex((header) => header.name === RANGE_DIMENSION),
145
+ 0,
146
+ );
147
+
148
+ // A period with no traffic gets no row at all, which reads as zeros.
149
+ const period = (name) => {
150
+ const row = (response.rows ?? []).find(
151
+ (r) => r.dimensionValues?.[rangeAt]?.value === name,
152
+ );
153
+ const m = row?.metricValues ?? [];
154
+
155
+ return {
156
+ views: num(m[0]?.value),
157
+ users: num(m[1]?.value),
158
+ engagementSeconds: num(m[2]?.value),
159
+ engagementRate: num(m[3]?.value),
160
+ sessions: num(m[4]?.value),
161
+ };
162
+ };
163
+
164
+ const cur = period(CURRENT_RANGE);
165
+ const prev = period(PREVIOUS_RANGE);
166
+
167
+ const avgDur = (x) => (x.sessions ? x.engagementSeconds / x.sessions : 0);
168
+
169
+ return {
170
+ views: {
171
+ value: cur.views,
172
+ previous: prev.views,
173
+ trend: trend(cur.views, prev.views),
174
+ },
175
+ users: {
176
+ value: cur.users,
177
+ previous: prev.users,
178
+ trend: trend(cur.users, prev.users),
179
+ },
180
+ avgDuration: {
181
+ seconds: avgDur(cur),
182
+ previous: avgDur(prev),
183
+ trend: trend(avgDur(cur), avgDur(prev)),
184
+ },
185
+ engagement: {
186
+ rate: cur.engagementRate,
187
+ previous: prev.engagementRate,
188
+ trend: trend(cur.engagementRate, prev.engagementRate),
189
+ },
190
+ };
191
+ }
192
+
193
+ // Summary KPIs with period-over-period trend (needs absolute ISO dates).
194
+ export async function runSummary({ pagePath, startDate, endDate, ga }) {
195
+ return parseSummary(
196
+ await runReport(summaryRequest({ pagePath, startDate, endDate }), ga),
197
+ );
198
+ }
199
+
200
+ // The chart plots views and nothing else, so that is all the report asks for.
201
+ const seriesRequest = ({ pagePath, startDate, endDate }) => ({
202
+ dimensions: [{ name: "date" }],
203
+ metrics: [{ name: "screenPageViews" }],
204
+ dateRanges: [{ startDate, endDate }],
205
+ dimensionFilter: pageFilter(pagePath),
206
+ orderBys: [{ dimension: { dimensionName: "date" } }],
207
+ });
208
+
209
+ function parseSeries(response, { startDate, endDate }) {
210
+ const returned = (response.rows ?? []).map((row) => ({
211
+ date: fmtDate(row.dimensionValues?.[0]?.value),
212
+ views: num(row.metricValues?.[0]?.value),
213
+ }));
214
+
215
+ // Backfill days GA omitted for zero-activity periods so the chart line
216
+ // stays continuous (no false gaps between non-adjacent dates).
217
+ const days = expectedDays(startDate, endDate);
218
+ let rows = returned;
219
+ if (days) {
220
+ const byDate = new Map(returned.map((r) => [r.date, r]));
221
+ rows = days.map((date) => byDate.get(date) ?? { date, views: 0 });
222
+ }
223
+
224
+ return { rows, dateRange: { startDate, endDate } };
225
+ }
226
+
227
+ // Daily page-views series for one page.
228
+ export async function runPageReport({ pagePath, startDate, endDate, ga }) {
229
+ const response = await runReport(
230
+ seriesRequest({ pagePath, startDate, endDate }),
231
+ ga,
232
+ );
233
+
234
+ return parseSeries(response, { startDate, endDate });
235
+ }
236
+
237
+ // The server's calendar date, not the UTC one. toISOString answers in UTC, so
238
+ // on a host behind or ahead of it the window would start and end on the wrong
239
+ // day for part of every day.
240
+ //
241
+ // GA reads these dates in the property's own timezone, so a property that
242
+ // doesn't share the server's timezone can still be a day out at the edges —
243
+ // that one needs config.ga.timezone and is not what this fixes.
244
+ const localDay = (date) =>
245
+ [
246
+ date.getFullYear(),
247
+ String(date.getMonth() + 1).padStart(2, "0"),
248
+ String(date.getDate()).padStart(2, "0"),
249
+ ].join("-");
250
+
251
+ // Last-N-days window ending today.
252
+ function lastNDays(days = 30) {
253
+ const end = new Date();
254
+ const start = new Date();
255
+ start.setDate(end.getDate() - (days - 1));
256
+ return { startDate: localDay(start), endDate: localDay(end) };
257
+ }
258
+
259
+ // Quick Insights for a page path: summary KPIs and the daily views series over
260
+ // the last 30 days. Swallows nothing — caller handles errors.
261
+ //
262
+ // cacheKey identifies the resource this path belongs to (see reportCacheKey in
263
+ // reportsUtils) so one resource gets one entry; it falls back to the path for
264
+ // callers that have no resource at hand.
265
+ export async function pageInsights(pagePath, ga, { cacheKey } = {}) {
266
+ const { startDate, endDate } = lastNDays(30);
267
+ const key = `ga:insights:${cacheKey || pagePath}`;
268
+
269
+ return localCache.fetch(
270
+ key,
271
+ async () => {
272
+ // Three periods over two report shapes, one HTTP request: the summary
273
+ // asks for its current and previous period as two date ranges, and it
274
+ // rides along with the daily series in a batch. The two can't collapse
275
+ // into a single report — the series is broken down by date and the
276
+ // summary isn't, and totals over a rate metric are not a number we want
277
+ // to infer.
278
+ const [summary, series] = await batchRunReports(
279
+ [
280
+ summaryRequest({ pagePath, startDate, endDate }),
281
+ seriesRequest({ pagePath, startDate, endDate }),
282
+ ],
283
+ ga,
284
+ );
285
+
286
+ return {
287
+ summary: parseSummary(summary ?? {}),
288
+ rows: parseSeries(series ?? {}, { startDate, endDate }).rows,
289
+ dateRange: { startDate, endDate },
290
+ };
291
+ },
292
+ { ttl: GA_CACHE_TTL },
293
+ );
294
+ }