@liiift-studio/sanity-visitor-insights 0.27.1 → 0.29.0
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.
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +183 -84
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +187 -88
- package/dist/index.mjs.map +1 -1
- package/dist/{ranges-_SBvGBJx.d.mts → ranges-D1A9JY7h.d.mts} +33 -9
- package/dist/{ranges-_SBvGBJx.d.ts → ranges-D1A9JY7h.d.ts} +33 -9
- package/dist/server.d.mts +2 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +96 -23
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +96 -23
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
- package/src/reportData.ts +33 -9
- package/src/server/reports/acquisition.ts +161 -16
- package/src/server/reports/measurementHealth.ts +41 -10
- package/src/server/reports/reports.test.ts +5 -1
- package/src/studio/CrossSourceTimeline.tsx +168 -5
- package/src/studio/Figure.tsx +29 -5
- package/src/studio/VisitorInsightsTool.tsx +3 -3
- package/src/studio/panels.test.tsx +217 -24
- package/src/studio/panels.tsx +83 -39
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as sanity from 'sanity';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-
|
|
4
|
-
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-
|
|
3
|
+
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-D1A9JY7h.mjs';
|
|
4
|
+
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-D1A9JY7h.mjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Data hook for the Studio panels.
|
|
@@ -117,7 +117,7 @@ declare function formatCount(value: number): string;
|
|
|
117
117
|
declare function formatPercent(ratio: number, digits?: number): string;
|
|
118
118
|
/** Props for MetricFigure. */
|
|
119
119
|
/** Format a money value in the site's currency, falling back to a plain number. */
|
|
120
|
-
declare function formatMoney(value: number, currency: string | null): string;
|
|
120
|
+
declare function formatMoney(value: number, currency: string | null, fractionDigits?: 0 | 2): string;
|
|
121
121
|
/** Props for Delta. */
|
|
122
122
|
interface DeltaProps {
|
|
123
123
|
/** This period's value, or null when it could not be measured. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as sanity from 'sanity';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-
|
|
4
|
-
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-
|
|
3
|
+
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-D1A9JY7h.js';
|
|
4
|
+
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-D1A9JY7h.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Data hook for the Studio panels.
|
|
@@ -117,7 +117,7 @@ declare function formatCount(value: number): string;
|
|
|
117
117
|
declare function formatPercent(ratio: number, digits?: number): string;
|
|
118
118
|
/** Props for MetricFigure. */
|
|
119
119
|
/** Format a money value in the site's currency, falling back to a plain number. */
|
|
120
|
-
declare function formatMoney(value: number, currency: string | null): string;
|
|
120
|
+
declare function formatMoney(value: number, currency: string | null, fractionDigits?: 0 | 2): string;
|
|
121
121
|
/** Props for Delta. */
|
|
122
122
|
interface DeltaProps {
|
|
123
123
|
/** This period's value, or null when it could not be measured. */
|
package/dist/index.js
CHANGED
|
@@ -219,19 +219,25 @@ var REASON_TEXT = {
|
|
|
219
219
|
source_error: "Source did not respond",
|
|
220
220
|
not_applicable: "Does not apply to this site"
|
|
221
221
|
};
|
|
222
|
+
var LOCALE = "en-GB";
|
|
222
223
|
function formatCount(value) {
|
|
223
|
-
return new Intl.NumberFormat(
|
|
224
|
+
return new Intl.NumberFormat(LOCALE).format(Math.round(value));
|
|
224
225
|
}
|
|
225
226
|
function formatPercent(ratio, digits = 0) {
|
|
226
227
|
return `${(ratio * 100).toFixed(digits)}%`;
|
|
227
228
|
}
|
|
228
|
-
function formatMoney(value, currency) {
|
|
229
|
+
function formatMoney(value, currency, fractionDigits = 0) {
|
|
229
230
|
if (!currency) return formatCount(Math.round(value));
|
|
230
231
|
try {
|
|
231
|
-
return new Intl.NumberFormat(
|
|
232
|
+
return new Intl.NumberFormat(LOCALE, {
|
|
232
233
|
style: "currency",
|
|
233
234
|
currency,
|
|
234
|
-
|
|
235
|
+
// One precision for the whole column, chosen by the caller, not by each value's own size.
|
|
236
|
+
// Switching at 1,000 put "$850.00" and "$1,200" in adjacent rows, and printed an axis
|
|
237
|
+
// reading "$1,000" over a baseline reading "$0.00" — the mismatch the timeline's own
|
|
238
|
+
// comment claims to have fixed by routing both ends through this function.
|
|
239
|
+
maximumFractionDigits: fractionDigits,
|
|
240
|
+
minimumFractionDigits: fractionDigits
|
|
235
241
|
}).format(value);
|
|
236
242
|
} catch {
|
|
237
243
|
return `${formatCount(Math.round(value))} ${currency}`;
|
|
@@ -242,7 +248,8 @@ function Delta({ current, previous, riseIsGood = true, unit = "count" }) {
|
|
|
242
248
|
const now = current;
|
|
243
249
|
const before = previous;
|
|
244
250
|
const absolute = now - before;
|
|
245
|
-
|
|
251
|
+
const roundsToNothing = unit === "percent" ? Math.abs(absolute) < 0.05 : before !== 0 && Math.abs(absolute / Math.abs(before)) < 5e-3;
|
|
252
|
+
if (absolute === 0 || roundsToNothing) {
|
|
246
253
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: deltaStyle("flat"), children: "no change" });
|
|
247
254
|
}
|
|
248
255
|
const change = before === 0 ? null : absolute / Math.abs(before);
|
|
@@ -1081,11 +1088,49 @@ function dayIndexAt(clientX, left, boxWidth, viewBoxWidth, plotWidth, count) {
|
|
|
1081
1088
|
const index = Math.round((clamped - GUTTER) / plotWidth * (count - 1));
|
|
1082
1089
|
return Math.max(0, Math.min(count - 1, index));
|
|
1083
1090
|
}
|
|
1091
|
+
function findCoverageIncident(days, dates) {
|
|
1092
|
+
const measured = days.filter((d) => d !== null);
|
|
1093
|
+
if (measured.length < 21) return null;
|
|
1094
|
+
const sorted = [...measured].sort((a, b) => a - b);
|
|
1095
|
+
const normal = sorted[Math.floor(sorted.length / 2)];
|
|
1096
|
+
if (normal <= 0.1) return null;
|
|
1097
|
+
const threshold = normal * 0.5;
|
|
1098
|
+
const MIN_RUN = 3;
|
|
1099
|
+
let best = null;
|
|
1100
|
+
let runStart = null;
|
|
1101
|
+
for (let i = 0; i <= days.length; i++) {
|
|
1102
|
+
const value = days[i];
|
|
1103
|
+
const low = i < days.length && value !== null && value !== void 0 && value < threshold;
|
|
1104
|
+
if (low && runStart === null) runStart = i;
|
|
1105
|
+
if (!low && runStart !== null) {
|
|
1106
|
+
const length = i - runStart;
|
|
1107
|
+
if (length >= MIN_RUN && (!best || length > best.end - best.start)) best = { start: runStart, end: i };
|
|
1108
|
+
runStart = null;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (!best) return null;
|
|
1112
|
+
const during = days.slice(best.start, best.end).filter((d) => d !== null);
|
|
1113
|
+
const before = days.slice(0, best.start).filter((d) => d !== null);
|
|
1114
|
+
if (before.length < 3 || during.length === 0) return null;
|
|
1115
|
+
const mean = (values) => values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
1116
|
+
const onset = dates[best.start];
|
|
1117
|
+
if (!onset) return null;
|
|
1118
|
+
return {
|
|
1119
|
+
onset,
|
|
1120
|
+
days: best.end - best.start,
|
|
1121
|
+
before: mean(before),
|
|
1122
|
+
during: mean(during),
|
|
1123
|
+
// The run reaching the end of the series is the difference between "this happened" and "this
|
|
1124
|
+
// is happening", which is the difference between a note and a job for today.
|
|
1125
|
+
ongoing: best.end >= days.length
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1084
1128
|
function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
1085
1129
|
const [hoverIndex, setHoverIndex] = (0, import_react3.useState)(null);
|
|
1086
1130
|
const [pinned, setPinned] = (0, import_react3.useState)(false);
|
|
1087
1131
|
const [brushAnchor, setBrushAnchor] = (0, import_react3.useState)(null);
|
|
1088
1132
|
const [brushed, setBrushed] = (0, import_react3.useState)(null);
|
|
1133
|
+
const [steppedByKeyboard, setSteppedByKeyboard] = (0, import_react3.useState)(false);
|
|
1089
1134
|
const [tooShort, setTooShort] = (0, import_react3.useState)(false);
|
|
1090
1135
|
const [measured, setMeasured] = (0, import_react3.useState)(WIDTH);
|
|
1091
1136
|
const frameRef = (0, import_react3.useRef)(null);
|
|
@@ -1149,6 +1194,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1149
1194
|
(event) => {
|
|
1150
1195
|
const index = indexAt(event.clientX);
|
|
1151
1196
|
if (index === null) return;
|
|
1197
|
+
setSteppedByKeyboard(false);
|
|
1152
1198
|
setHoverIndex(index);
|
|
1153
1199
|
if (brushAnchor !== null) {
|
|
1154
1200
|
const from = Math.min(brushAnchor, index);
|
|
@@ -1203,9 +1249,20 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1203
1249
|
}
|
|
1204
1250
|
const sentences = [];
|
|
1205
1251
|
if (excess > 0) {
|
|
1206
|
-
sentences.push(`${row.shortfall.source} counted ${formatValue(Math.round(excess), row.unit, currency)}
|
|
1252
|
+
sentences.push(`${row.shortfall.source} counted ${formatValue(Math.round(excess), row.unit, currency)} more ${row.label.toLowerCase()} than ${row.source} on some days, which usually means a tag firing twice rather than extra traffic.`);
|
|
1207
1253
|
}
|
|
1208
1254
|
if (gap <= 0) return sentences;
|
|
1255
|
+
const coverageByDay = row.points.map((point) => {
|
|
1256
|
+
if (point.value === null || point.value <= 0) return null;
|
|
1257
|
+
const saw = seen.get(point.date);
|
|
1258
|
+
return saw == null ? null : saw / point.value;
|
|
1259
|
+
});
|
|
1260
|
+
const incident = findCoverageIncident(coverageByDay, row.points.map((p) => p.date));
|
|
1261
|
+
if (incident) {
|
|
1262
|
+
sentences.push(
|
|
1263
|
+
`${row.shortfall.source} coverage fell from about ${formatPercent(incident.before, 0)} to ${formatPercent(incident.during, 0)} on ${tickLabel(/* @__PURE__ */ new Date(`${incident.onset}T00:00:00Z`))}` + (incident.ongoing ? `, and has stayed there for ${incident.days} days. That is a change on a date, not a standing shortfall \u2014 something altered on or around then.` : ` for ${incident.days} days, then recovered.`)
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1209
1266
|
const total = `${row.shortfall.source} missed ${formatValue(Math.round(gap), row.unit, currency)} ${row.label.toLowerCase()} over this period`;
|
|
1210
1267
|
sentences.push(worst && worst.ratio > 0 ? `${total}; the gap was widest on ${tickLabel(/* @__PURE__ */ new Date(`${worst.date}T00:00:00Z`))} at ${formatPercent(worst.ratio, 0)}.` : `${total}.`);
|
|
1211
1268
|
return sentences;
|
|
@@ -1222,7 +1279,6 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1222
1279
|
setHoverIndex(null);
|
|
1223
1280
|
setBrushAnchor(null);
|
|
1224
1281
|
setBrushed(null);
|
|
1225
|
-
setTooShort(false);
|
|
1226
1282
|
},
|
|
1227
1283
|
onPointerDown: (event) => {
|
|
1228
1284
|
if (!onBrush) return;
|
|
@@ -1272,9 +1328,11 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1272
1328
|
}
|
|
1273
1329
|
if (next === null) return;
|
|
1274
1330
|
event.preventDefault();
|
|
1331
|
+
setSteppedByKeyboard(true);
|
|
1275
1332
|
setHoverIndex(next);
|
|
1276
1333
|
if (event.shiftKey && onBrush) {
|
|
1277
|
-
const anchor =
|
|
1334
|
+
const anchor = brushAnchor ?? current;
|
|
1335
|
+
if (brushAnchor === null) setBrushAnchor(current);
|
|
1278
1336
|
setBrushed([Math.min(anchor, next), Math.max(anchor, next)]);
|
|
1279
1337
|
}
|
|
1280
1338
|
},
|
|
@@ -1296,10 +1354,12 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1296
1354
|
const pitch = plotWidth / Math.max(1, dates.length - 1);
|
|
1297
1355
|
const stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch));
|
|
1298
1356
|
const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value !== 0) : [];
|
|
1299
|
-
const zeroes = row.mark === "events" ? row.points.filter((p) => p.value === 0) : [];
|
|
1357
|
+
const zeroes = row.mark === "events" && pitch >= 6 ? row.points.filter((p) => p.value === 0) : [];
|
|
1300
1358
|
const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
|
|
1301
1359
|
const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints) ?? "" : "";
|
|
1360
|
+
const clipId = `row-clip-${rowIndex}`;
|
|
1302
1361
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
|
|
1362
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("clipPath", { id: clipId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("rect", { x: GUTTER, y: plotTop, width: plotWidth, height: Math.max(1, bottom - plotTop) }) }) }),
|
|
1303
1363
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: bottom, y2: bottom, stroke: "currentColor", strokeWidth: 1, opacity: 0.45 }),
|
|
1304
1364
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER + 4, y: top + 11, fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.85, fontWeight: 500, children: row.label }),
|
|
1305
1365
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("text", { x: GUTTER + plotWidth, y: top + 11, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.72, children: [
|
|
@@ -1308,52 +1368,54 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1308
1368
|
] }),
|
|
1309
1369
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER - 6, y: y(y.domain()[1]) + 4, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.7, children: formatValue(y.domain()[1], row.unit, currency) }),
|
|
1310
1370
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER - 6, y: bottom + 4, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.7, children: formatValue(0, row.unit, currency) }),
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1371
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { clipPath: `url(#${clipId})`, children: [
|
|
1372
|
+
gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
|
|
1373
|
+
shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
|
|
1374
|
+
zeroes.map((p) => {
|
|
1375
|
+
const cx = at(p);
|
|
1376
|
+
if (!Number.isFinite(cx)) return null;
|
|
1377
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1378
|
+
"rect",
|
|
1379
|
+
{
|
|
1380
|
+
x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
|
|
1381
|
+
y: bottom - 3,
|
|
1382
|
+
width: stemWidth,
|
|
1383
|
+
height: 3,
|
|
1384
|
+
fill: "currentColor",
|
|
1385
|
+
opacity: 0.55
|
|
1386
|
+
},
|
|
1387
|
+
`zero-${p.date}`
|
|
1388
|
+
);
|
|
1389
|
+
}),
|
|
1390
|
+
stems.map((p) => {
|
|
1391
|
+
const cx = at(p);
|
|
1392
|
+
const headY = y(p.value);
|
|
1393
|
+
if (!Number.isFinite(cx) || !Number.isFinite(headY)) return null;
|
|
1394
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1395
|
+
"rect",
|
|
1396
|
+
{
|
|
1397
|
+
x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
|
|
1398
|
+
y: Math.min(headY, bottom),
|
|
1399
|
+
width: stemWidth,
|
|
1400
|
+
height: Math.max(1.5, Math.abs(bottom - headY)),
|
|
1401
|
+
fill: "currentColor",
|
|
1402
|
+
opacity: 0.75
|
|
1403
|
+
},
|
|
1404
|
+
p.date
|
|
1405
|
+
);
|
|
1406
|
+
}),
|
|
1407
|
+
path && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1408
|
+
"path",
|
|
1335
1409
|
{
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
opacity: 0.
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
}),
|
|
1346
|
-
path && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1347
|
-
"path",
|
|
1348
|
-
{
|
|
1349
|
-
d: path,
|
|
1350
|
-
fill: "none",
|
|
1351
|
-
stroke: "currentColor",
|
|
1352
|
-
strokeWidth: 2,
|
|
1353
|
-
strokeDasharray: row.complete ? void 0 : "6 4",
|
|
1354
|
-
opacity: row.complete ? 0.95 : 0.7
|
|
1355
|
-
}
|
|
1356
|
-
)
|
|
1410
|
+
d: path,
|
|
1411
|
+
fill: "none",
|
|
1412
|
+
stroke: "currentColor",
|
|
1413
|
+
strokeWidth: 2,
|
|
1414
|
+
strokeDasharray: row.complete ? void 0 : "6 4",
|
|
1415
|
+
opacity: row.complete ? 0.95 : 0.7
|
|
1416
|
+
}
|
|
1417
|
+
)
|
|
1418
|
+
] })
|
|
1357
1419
|
] }, row.key);
|
|
1358
1420
|
}),
|
|
1359
1421
|
markers.map((marker) => {
|
|
@@ -1412,6 +1474,25 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1412
1474
|
opacity: 0.8
|
|
1413
1475
|
}
|
|
1414
1476
|
),
|
|
1477
|
+
tickIndexes.map((index) => {
|
|
1478
|
+
const date = dates[index];
|
|
1479
|
+
if (!date) return null;
|
|
1480
|
+
const at = x(/* @__PURE__ */ new Date(`${date}T00:00:00Z`));
|
|
1481
|
+
if (!Number.isFinite(at)) return null;
|
|
1482
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1483
|
+
"line",
|
|
1484
|
+
{
|
|
1485
|
+
x1: at,
|
|
1486
|
+
x2: at,
|
|
1487
|
+
y1: TOP_PAD,
|
|
1488
|
+
y2: height - AXIS_HEIGHT,
|
|
1489
|
+
stroke: "currentColor",
|
|
1490
|
+
strokeWidth: 1,
|
|
1491
|
+
opacity: 0.08
|
|
1492
|
+
},
|
|
1493
|
+
`grid-${date}`
|
|
1494
|
+
);
|
|
1495
|
+
}),
|
|
1415
1496
|
tickIndexes.map((index, position) => {
|
|
1416
1497
|
const date = dates[index];
|
|
1417
1498
|
if (!date) return null;
|
|
@@ -1432,7 +1513,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
|
|
|
1432
1513
|
]
|
|
1433
1514
|
}
|
|
1434
1515
|
) }),
|
|
1435
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: readoutRow, "aria-live": "polite", children: [
|
|
1516
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: readoutRow, "aria-live": steppedByKeyboard ? "polite" : "off", children: [
|
|
1436
1517
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, weight: "medium", children: brushed && brushed[1] > brushed[0] && brushed[1] < dates.length ? `${tickLabel(/* @__PURE__ */ new Date(`${dates[brushed[0]]}T00:00:00Z`))} \u2013 ${tickLabel(/* @__PURE__ */ new Date(`${dates[brushed[1]]}T00:00:00Z`))} \xB7 ${brushed[1] - brushed[0] + 1} days` : hoveredDate ? tickLabel(/* @__PURE__ */ new Date(`${hoveredDate}T00:00:00Z`)) : "Hover the chart to read a day" }),
|
|
1437
1518
|
hoveredDate && series.map((row) => {
|
|
1438
1519
|
const point = row.points.find((p) => p.date === hoveredDate);
|
|
@@ -1672,7 +1753,7 @@ function OverviewPanel({ data, previous, onBrush }) {
|
|
|
1672
1753
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
1673
1754
|
ChartData,
|
|
1674
1755
|
{
|
|
1675
|
-
note: "A negative miss means GA4 counted
|
|
1756
|
+
note: "A negative miss means GA4 counted more than Vercel that day \u2014 usually a tag firing twice, not extra traffic.",
|
|
1676
1757
|
label: "Show these figures as a table",
|
|
1677
1758
|
rows: data.crossSource ?? [],
|
|
1678
1759
|
rowKey: (d) => d.date,
|
|
@@ -2031,7 +2112,9 @@ function AcquisitionPanel({ data, previous }) {
|
|
|
2031
2112
|
* total and the money column is empty.
|
|
2032
2113
|
*/
|
|
2033
2114
|
key: "brought",
|
|
2034
|
-
|
|
2115
|
+
// The hedge belongs on the SPLIT, not on the money: the total is exact and the
|
|
2116
|
+
// division of it is the estimate. "(est.)" on a revenue header implied the reverse.
|
|
2117
|
+
label: "Revenue, split",
|
|
2035
2118
|
numeric: true,
|
|
2036
2119
|
sortValue: (row) => finiteOrNull(row.revenueShare),
|
|
2037
2120
|
exportValue: (row) => {
|
|
@@ -2040,7 +2123,7 @@ function AcquisitionPanel({ data, previous }) {
|
|
|
2040
2123
|
},
|
|
2041
2124
|
render: (row) => {
|
|
2042
2125
|
const share = finiteOrNull(row.revenueShare);
|
|
2043
|
-
if (share === null) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" });
|
|
2126
|
+
if (share === null || share === 0) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" });
|
|
2044
2127
|
const money = finiteOrNull(row.apportionedRevenue);
|
|
2045
2128
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 1, children: [
|
|
2046
2129
|
money !== null && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatMoney(money, data.currency ?? null) }),
|
|
@@ -2054,7 +2137,7 @@ function AcquisitionPanel({ data, previous }) {
|
|
|
2054
2137
|
]
|
|
2055
2138
|
}
|
|
2056
2139
|
),
|
|
2057
|
-
|
|
2140
|
+
data.splitIsSound === true && finiteOrNull(data.actualRevenue) !== null && // Says exactly what was combined and what was estimated. The money in that column
|
|
2058
2141
|
// is Sanity's real total spread across GA4's split — right in scale, and derived,
|
|
2059
2142
|
// which is a different claim from measured. Saying so here is what makes the
|
|
2060
2143
|
// column usable rather than another figure to distrust.
|
|
@@ -2065,10 +2148,16 @@ function AcquisitionPanel({ data, previous }) {
|
|
|
2065
2148
|
" ",
|
|
2066
2149
|
finiteOrNull(data.actualOrders) === null ? "your orders" : `${formatCount(data.actualOrders)} orders`,
|
|
2067
2150
|
" in Sanity, split across the ",
|
|
2068
|
-
formatCount(data.trackedPurchases),
|
|
2151
|
+
formatCount(data.trackedPurchases ?? 0),
|
|
2069
2152
|
" purchase",
|
|
2070
2153
|
data.trackedPurchases === 1 ? "" : "s",
|
|
2071
|
-
" Google Analytics attributed to a source. Google Analytics sees a fraction of your traffic,
|
|
2154
|
+
" Google Analytics attributed to a source. Google Analytics sees a fraction of your traffic, so treat this as a proportion with a real total behind it rather than measured takings per channel. Two things to hold in mind: it credits whichever source a buyer arrived from on the visit they bought, which for a considered purchase is often a bookmark rather than the blog that first sent them; and its loss is not perfectly even across channels \u2014 ad-blocking runs high among designers, so earned referrals and direct visits are likelier to be under-credited than paid clicks.",
|
|
2155
|
+
finiteOrNull(data.shownPurchases) !== null && (data.shownPurchases ?? 0) < (data.trackedPurchases ?? 0) && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
2156
|
+
" ",
|
|
2157
|
+
"The rows above account for ",
|
|
2158
|
+
formatCount(data.shownPurchases ?? 0),
|
|
2159
|
+
" of those sales; the rest came from sources outside this list, so this column does not add up to the whole."
|
|
2160
|
+
] })
|
|
2072
2161
|
] })
|
|
2073
2162
|
] }),
|
|
2074
2163
|
data.rowsWithheld && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(NoticeList, { notices: ["GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality."] })
|
|
@@ -2151,24 +2240,31 @@ function JourneyPanel({ data }) {
|
|
|
2151
2240
|
] })
|
|
2152
2241
|
] });
|
|
2153
2242
|
}
|
|
2154
|
-
function
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
const
|
|
2158
|
-
|
|
2243
|
+
function catalogueRate(rows) {
|
|
2244
|
+
let views = 0;
|
|
2245
|
+
let sales = 0;
|
|
2246
|
+
for (const row of rows) {
|
|
2247
|
+
const viewed = metricSortValue(row.viewed);
|
|
2248
|
+
const bought = metricSortValue(row.bought);
|
|
2249
|
+
if (viewed === null || bought === null) continue;
|
|
2250
|
+
views += viewed;
|
|
2251
|
+
sales += bought;
|
|
2252
|
+
}
|
|
2253
|
+
return views > 0 && sales > 0 ? sales / views : null;
|
|
2159
2254
|
}
|
|
2160
|
-
function buyRateIndex(row,
|
|
2255
|
+
function buyRateIndex(row, benchmark) {
|
|
2161
2256
|
const rate = row.buyRate;
|
|
2162
|
-
if (
|
|
2163
|
-
|
|
2257
|
+
if (benchmark === null || benchmark <= 0) return null;
|
|
2258
|
+
if (rate === null || rate === void 0 || !Number.isFinite(rate) || rate < 0) return null;
|
|
2259
|
+
return rate / benchmark;
|
|
2164
2260
|
}
|
|
2165
2261
|
function TypefaceInterestPanel({ data }) {
|
|
2166
|
-
const
|
|
2262
|
+
const benchmark = catalogueRate(data.rows ?? []);
|
|
2167
2263
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
|
|
2168
2264
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: data.interpretationNote }) }),
|
|
2169
2265
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
|
|
2170
2266
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Engagement by typeface" }),
|
|
2171
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Sort by any column. \u201CSells vs catalogue\u201D compares each family\u2019s
|
|
2267
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Sort by any column. \u201CSells vs catalogue\u201D compares each family\u2019s sales-per-view against the catalogue as a whole \u2014 1.0\xD7 is average, and sorting up finds the families that are looked at and do not sell. It is a comparison between your families, not the share of visitors who buy: Google Analytics sees only a fraction of the views, which moves every family together and so cancels out here. With one or two sales a family will still swing a long way, so read it alongside the order counts beside it." }),
|
|
2172
2268
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2173
2269
|
SortableTable,
|
|
2174
2270
|
{
|
|
@@ -2249,24 +2345,26 @@ function TypefaceInterestPanel({ data }) {
|
|
|
2249
2345
|
key: "buyRate",
|
|
2250
2346
|
label: "Sells vs catalogue",
|
|
2251
2347
|
numeric: true,
|
|
2252
|
-
|
|
2348
|
+
// The INDEX, so a row rendered as a dash cannot sort above a row with a value.
|
|
2349
|
+
// Sorting on the raw rate returned 0 — a real number — for a family whose
|
|
2350
|
+
// cell showed "—", so ascending put a block of dashes at the top of the
|
|
2351
|
+
// table, which is the one thing this file's header forbids.
|
|
2352
|
+
sortValue: (row) => buyRateIndex(row, benchmark),
|
|
2253
2353
|
exportValue: (row) => {
|
|
2254
|
-
const index = buyRateIndex(row,
|
|
2255
|
-
return index === null ? null : `${index.toFixed(1)}x`;
|
|
2354
|
+
const index = buyRateIndex(row, benchmark);
|
|
2355
|
+
return index === null ? null : `${index.toFixed(1)}x catalogue`;
|
|
2256
2356
|
},
|
|
2257
2357
|
render: (row) => {
|
|
2258
|
-
const index = buyRateIndex(row,
|
|
2358
|
+
const index = buyRateIndex(row, benchmark);
|
|
2259
2359
|
if (index === null) {
|
|
2260
2360
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, "aria-label": `${row.typeface} not comparable`, children: "\u2014" });
|
|
2261
2361
|
}
|
|
2362
|
+
if (index === 0) {
|
|
2363
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: "No sales" });
|
|
2364
|
+
}
|
|
2262
2365
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, children: [
|
|
2263
2366
|
index.toFixed(1),
|
|
2264
|
-
"\xD7"
|
|
2265
|
-
" ",
|
|
2266
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: { opacity: 0.6, fontSize: "0.85em" }, children: [
|
|
2267
|
-
index >= 1.15 ? "above" : index <= 0.85 ? "below" : "at",
|
|
2268
|
-
" median"
|
|
2269
|
-
] })
|
|
2367
|
+
"\xD7 catalogue"
|
|
2270
2368
|
] });
|
|
2271
2369
|
}
|
|
2272
2370
|
},
|
|
@@ -2613,9 +2711,10 @@ function CoverageRibbon({ ratio }) {
|
|
|
2613
2711
|
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 3, radius: 2, tone: ratio > 0.6 ? "critical" : "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 1, children: [
|
|
2614
2712
|
"Every figure on this tab comes from Google Analytics, which is seeing about ",
|
|
2615
2713
|
seen,
|
|
2616
|
-
"% of this site\u2019s traffic in this window
|
|
2617
|
-
|
|
2618
|
-
|
|
2714
|
+
"% of this site\u2019s traffic in this window \u2014 so the real numbers are roughly",
|
|
2715
|
+
" ",
|
|
2716
|
+
seen > 0 ? `${(100 / seen).toFixed(1)}\xD7` : "many times",
|
|
2717
|
+
" these. Data health explains why."
|
|
2619
2718
|
] }) });
|
|
2620
2719
|
}
|
|
2621
2720
|
function ReadyReport({
|