@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 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-_SBvGBJx.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-_SBvGBJx.mjs';
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-_SBvGBJx.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-_SBvGBJx.js';
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("en-GB").format(Math.round(value));
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(void 0, {
232
+ return new Intl.NumberFormat(LOCALE, {
232
233
  style: "currency",
233
234
  currency,
234
- maximumFractionDigits: value >= 1e3 ? 0 : 2
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
- if (absolute === 0) {
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)} MORE ${row.label.toLowerCase()} than ${row.source} on some days, which usually means a tag firing twice rather than extra traffic.`);
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 = brushed ? brushed[0] : current;
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
- gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
1312
- shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
1313
- zeroes.map((p) => {
1314
- const cx = at(p);
1315
- if (!Number.isFinite(cx)) return null;
1316
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1317
- "rect",
1318
- {
1319
- x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1320
- y: bottom - 3,
1321
- width: stemWidth,
1322
- height: 3,
1323
- fill: "currentColor",
1324
- opacity: 0.55
1325
- },
1326
- `zero-${p.date}`
1327
- );
1328
- }),
1329
- stems.map((p) => {
1330
- const cx = at(p);
1331
- const headY = y(p.value);
1332
- if (!Number.isFinite(cx) || !Number.isFinite(headY)) return null;
1333
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1334
- "rect",
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
- x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1337
- y: Math.min(headY, bottom),
1338
- width: stemWidth,
1339
- height: Math.max(1.5, Math.abs(bottom - headY)),
1340
- fill: "currentColor",
1341
- opacity: 0.75
1342
- },
1343
- p.date
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 MORE than Vercel that day \u2014 usually a tag firing twice, not extra traffic.",
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
- label: "Revenue (est.)",
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
- (data.trackedPurchases ?? 0) > 0 && finiteOrNull(data.actualRevenue) !== null && // Says exactly what was combined and what was estimated. The money in that column
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, but it loses it fairly evenly across channels \u2014 so the split is far more trustworthy than its own totals. Treat these as proportions with a real total behind them, not as measured takings per channel."
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 medianOf(values) {
2155
- const usable = values.filter((v) => v !== null && Number.isFinite(v) && v > 0).sort((a, b) => a - b);
2156
- if (usable.length < 3) return null;
2157
- const middle = Math.floor(usable.length / 2);
2158
- return usable.length % 2 === 0 ? (usable[middle - 1] + usable[middle]) / 2 : usable[middle];
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, median) {
2255
+ function buyRateIndex(row, benchmark) {
2161
2256
  const rate = row.buyRate;
2162
- if (median === null || median <= 0 || rate === null || rate === void 0 || !Number.isFinite(rate)) return null;
2163
- return rate / median;
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 medianBuyRate = medianOf((data.rows ?? []).map((row) => finiteOrNull(row.buyRate)));
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 orders-per-view against the median family, which is the part of that ratio that holds even though Google Analytics only sees a fraction of the views. It ranks families against each other; it is not the share of visitors who buy, and at these order volumes a family with one or two sales will swing a long way." }),
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
- sortValue: (row) => finiteOrNull(row.buyRate),
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, medianBuyRate);
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, medianBuyRate);
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. Read them as at least ",
2617
- seen === 0 ? "undercounts" : `${seen}% of the real numbers`,
2618
- ", not as what happened. Data health explains why."
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({