@liiift-studio/sanity-visitor-insights 0.24.0 → 0.25.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
@@ -32,7 +32,6 @@ interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolP
32
32
  options?: Partial<VisitorInsightsToolProps>;
33
33
  };
34
34
  }
35
- /** The tool itself. */
36
35
  declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
37
36
 
38
37
  /**
package/dist/index.d.ts CHANGED
@@ -32,7 +32,6 @@ interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolP
32
32
  options?: Partial<VisitorInsightsToolProps>;
33
33
  };
34
34
  }
35
- /** The tool itself. */
36
35
  declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
37
36
 
38
37
  /**
package/dist/index.js CHANGED
@@ -1060,6 +1060,7 @@ var GUTTER = 52;
1060
1060
  var RIGHT_PAD = 12;
1061
1061
  var TOP_PAD = 22;
1062
1062
  var AXIS_TYPE = 11;
1063
+ var LABEL_STRIP = 16;
1063
1064
  var MIN_BRUSH_DAYS = 3;
1064
1065
  function formatValue(value, unit, currency) {
1065
1066
  if (unit === "money") return formatMoney(value, currency ?? null);
@@ -1081,6 +1082,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1081
1082
  const [pinned, setPinned] = (0, import_react3.useState)(false);
1082
1083
  const [brushAnchor, setBrushAnchor] = (0, import_react3.useState)(null);
1083
1084
  const [brushed, setBrushed] = (0, import_react3.useState)(null);
1085
+ const [tooShort, setTooShort] = (0, import_react3.useState)(false);
1084
1086
  const [measured, setMeasured] = (0, import_react3.useState)(WIDTH);
1085
1087
  const frameRef = (0, import_react3.useRef)(null);
1086
1088
  const [frameNode, setFrameNode] = (0, import_react3.useState)(null);
@@ -1127,7 +1129,12 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1127
1129
  );
1128
1130
  const commit = (0, import_react3.useCallback)(
1129
1131
  (from, to) => {
1130
- if (!onBrush || to - from < MIN_BRUSH_DAYS - 1) return;
1132
+ if (!onBrush) return;
1133
+ if (to - from < MIN_BRUSH_DAYS - 1) {
1134
+ setTooShort(true);
1135
+ return;
1136
+ }
1137
+ setTooShort(false);
1131
1138
  const start = dates[from];
1132
1139
  const end = dates[to];
1133
1140
  if (start && end) onBrush(start, end);
@@ -1145,6 +1152,14 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1145
1152
  );
1146
1153
  if (dates.length < 3 || series.length === 0 || !x) return null;
1147
1154
  const hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null;
1155
+ const tickIndexes = (0, import_react3.useMemo)(() => {
1156
+ if (dates.length === 0) return [];
1157
+ if (dates.length === 1) return [0];
1158
+ const affordable = Math.max(2, Math.min(8, Math.floor(plotWidth / 90)));
1159
+ const step = (dates.length - 1) / (affordable - 1);
1160
+ const indexes = Array.from({ length: affordable }, (_, i) => Math.round(i * step));
1161
+ return [...new Set(indexes)];
1162
+ }, [dates.length, plotWidth]);
1148
1163
  const revealed = pinned || hoverIndex !== null;
1149
1164
  const shapeOf = (row) => {
1150
1165
  const real = row.points.filter((p) => p.value !== null);
@@ -1164,14 +1179,22 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1164
1179
  if (!row.shortfall) return [];
1165
1180
  const seen = new Map(row.shortfall.points.map((p) => [p.date, p.value]));
1166
1181
  let gap = 0;
1182
+ let excess = 0;
1167
1183
  let worst = null;
1184
+ const busiest = row.points.reduce((best, p) => Math.max(best, p.value ?? 0), 0);
1185
+ const floor = Math.max(1, busiest * 0.1);
1168
1186
  for (const point of row.points) {
1169
1187
  if (point.value === null) continue;
1170
1188
  const saw = seen.get(point.date);
1171
1189
  if (saw == null) continue;
1172
- gap += point.value - saw;
1190
+ const difference = point.value - saw;
1191
+ if (difference >= 0) gap += difference;
1192
+ else excess += -difference;
1173
1193
  const ratio = point.value > 0 ? 1 - saw / point.value : 0;
1174
- if (!worst || ratio > worst.ratio) worst = { date: point.date, ratio };
1194
+ if (point.value >= floor && (!worst || ratio > worst.ratio)) worst = { date: point.date, ratio };
1195
+ }
1196
+ if (excess > gap) {
1197
+ return [`${row.shortfall.source} counted ${formatCount(Math.round(excess - gap))} MORE ${row.label.toLowerCase()} than ${row.source} over this period, which usually means a tag firing twice rather than extra traffic.`];
1175
1198
  }
1176
1199
  if (gap <= 0 || !worst) return [];
1177
1200
  return [`${row.shortfall.source} missed ${formatCount(Math.round(gap))} ${row.label.toLowerCase()} over this period; the gap was widest on ${tickLabel(/* @__PURE__ */ new Date(`${worst.date}T00:00:00Z`))} at ${formatPercent(worst.ratio, 0)}.`];
@@ -1183,14 +1206,15 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1183
1206
  {
1184
1207
  ref: attachFrame,
1185
1208
  viewBox: `0 0 ${measured} ${height}`,
1186
- onMouseMove: onMove,
1187
- onMouseLeave: () => {
1209
+ onPointerMove: onMove,
1210
+ onPointerLeave: () => {
1188
1211
  setHoverIndex(null);
1189
1212
  setBrushAnchor(null);
1190
1213
  setBrushed(null);
1191
1214
  },
1192
1215
  onPointerDown: (event) => {
1193
1216
  if (!onBrush) return;
1217
+ setTooShort(false);
1194
1218
  const index = indexAt(event.clientX);
1195
1219
  if (index === null) return;
1196
1220
  event.preventDefault();
@@ -1246,9 +1270,10 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1246
1270
  series.map((row, rowIndex) => {
1247
1271
  const top = TOP_PAD + rowIndex * ROW_HEIGHT;
1248
1272
  const bottom = top + ROW_HEIGHT - 18;
1273
+ const plotTop = top + LABEL_STRIP;
1249
1274
  const values = row.points.map((p) => p.value).filter((v) => v !== null);
1250
1275
  const peak = (0, import_d3_array.max)(values) ?? 0;
1251
- const y = (0, import_d3_scale.scaleLinear)().domain([0, peak || 1]).nice().range([bottom, top]);
1276
+ const y = (0, import_d3_scale.scaleLinear)().domain([0, peak || 1]).nice().range([bottom, plotTop]);
1252
1277
  const at = (p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`));
1253
1278
  const defined = (p) => p.value !== null;
1254
1279
  const lineGen = (0, import_d3_shape.line)().defined(defined).x(at).y((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
@@ -1264,13 +1289,13 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1264
1289
  const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints.filter((p) => p.value !== null)) ?? "" : "";
1265
1290
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
1266
1291
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: bottom, y2: bottom, stroke: "currentColor", strokeWidth: 1, opacity: 0.45 }),
1267
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER + 4, y: top + 12, fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.7, fontWeight: 500, children: row.label }),
1268
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("text", { x: GUTTER + plotWidth, y: top + 12, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.5, children: [
1292
+ /* @__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 }),
1293
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("text", { x: GUTTER + plotWidth, y: top + 11, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.72, children: [
1269
1294
  row.source,
1270
1295
  row.complete ? "" : " \xB7 partial"
1271
1296
  ] }),
1272
1297
  /* @__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) }),
1273
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER - 6, y: bottom + 4, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.7, children: "0" }),
1298
+ /* @__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) }),
1274
1299
  gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
1275
1300
  shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
1276
1301
  zeroes.map((p) => {
@@ -1375,7 +1400,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1375
1400
  opacity: 0.8
1376
1401
  }
1377
1402
  ),
1378
- [0, Math.floor(dates.length / 2), dates.length - 1].map((index, position) => {
1403
+ tickIndexes.map((index, position) => {
1379
1404
  const date = dates[index];
1380
1405
  if (!date) return null;
1381
1406
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
@@ -1383,10 +1408,10 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1383
1408
  {
1384
1409
  x: x(/* @__PURE__ */ new Date(`${date}T00:00:00Z`)),
1385
1410
  y: height - 6,
1386
- textAnchor: position === 0 ? "start" : position === 2 ? "end" : "middle",
1411
+ textAnchor: position === 0 ? "start" : position === tickIndexes.length - 1 ? "end" : "middle",
1387
1412
  fontSize: AXIS_TYPE,
1388
1413
  fill: "currentColor",
1389
- opacity: 0.55,
1414
+ opacity: 0.72,
1390
1415
  children: tickLabel(/* @__PURE__ */ new Date(`${date}T00:00:00Z`))
1391
1416
  },
1392
1417
  date
@@ -1448,7 +1473,10 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1448
1473
  )),
1449
1474
  series.some((row) => row.shortfall) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Shaded: what your analytics did not see." }),
1450
1475
  markers.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Vertical rules mark campaign sends." }),
1451
- onBrush && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Drag across at least three days \u2014 or hold shift and use the arrow keys, then Enter \u2014 to narrow every panel to that span." })
1476
+ onBrush && // The refusal replaces the instruction rather than sitting beside it, and it is a
1477
+ // live region: the reader has just dragged and is looking at the chart, not at the
1478
+ // small print under it.
1479
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: !tooShort, "aria-live": "polite", children: tooShort ? `That span was too short to apply \u2014 the chart needs at least ${MIN_BRUSH_DAYS} days. Drag a little wider.` : "Drag across at least three days \u2014 or hold shift and use the arrow keys, then Enter \u2014 to narrow every panel to that span." })
1452
1480
  ] })
1453
1481
  ] });
1454
1482
  }
@@ -1520,6 +1548,14 @@ var cardGrid = {
1520
1548
  gridTemplateColumns: "repeat(auto-fit, minmax(min(14rem, 100%), 1fr))",
1521
1549
  gap: 16
1522
1550
  };
1551
+ function gapOf(day) {
1552
+ if (day.vercelPageviews === null || day.ga4Pageviews === null) return null;
1553
+ return day.vercelPageviews - day.ga4Pageviews;
1554
+ }
1555
+ function coverageOf(day) {
1556
+ if (day.vercelPageviews === null || day.ga4Pageviews === null || day.vercelPageviews <= 0) return null;
1557
+ return day.ga4Pageviews / day.vercelPageviews;
1558
+ }
1523
1559
  function OverviewPanel({ data, previous, onBrush }) {
1524
1560
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
1525
1561
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Verdict, { data, previous }),
@@ -1644,6 +1680,44 @@ function OverviewPanel({ data, previous, onBrush }) {
1644
1680
  sortValue: (d) => d.ga4Pageviews,
1645
1681
  render: (d) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: d.ga4Pageviews === null ? "\u2014" : formatCount(d.ga4Pageviews) })
1646
1682
  },
1683
+ {
1684
+ /*
1685
+ * The disagreement, as a number you can sort on.
1686
+ *
1687
+ * This table held `Pageviews` and `Seen by GA4` as separate columns and
1688
+ * nothing joining them, so the one quantity this whole tool exists to
1689
+ * surface was the one column missing from the only table that could hold
1690
+ * it. "Which day did GA4 lose most" was answerable by eye, or by the
1691
+ * single worst-day sentence under the chart, and by nothing else — not
1692
+ * sortable, not filterable, not in the CSV.
1693
+ */
1694
+ key: "missed",
1695
+ label: "Missed by GA4",
1696
+ numeric: true,
1697
+ sortValue: (d) => gapOf(d),
1698
+ exportValue: (d) => gapOf(d),
1699
+ render: (d) => {
1700
+ const gap = gapOf(d);
1701
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: gap === null ? "\u2014" : formatCount(gap) });
1702
+ }
1703
+ },
1704
+ {
1705
+ /*
1706
+ * The same disagreement as a proportion, because the two rank days
1707
+ * differently and both questions are real: the absolute column finds the
1708
+ * day that cost the most, this one finds the day the instrument worked
1709
+ * worst. Sorting on it is how a reader dates a collapse.
1710
+ */
1711
+ key: "coverage",
1712
+ label: "GA4 coverage",
1713
+ numeric: true,
1714
+ sortValue: (d) => coverageOf(d),
1715
+ exportValue: (d) => coverageOf(d),
1716
+ render: (d) => {
1717
+ const coverage = coverageOf(d);
1718
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: coverage === null ? "\u2014" : formatPercent(coverage, 0) });
1719
+ }
1720
+ },
1647
1721
  {
1648
1722
  key: "orders",
1649
1723
  label: "Orders",
@@ -2522,11 +2596,26 @@ var COMPARED_TABS = ["overview", "acquisition"];
2522
2596
  var PanelBoundary = class extends import_react4.default.Component {
2523
2597
  constructor(props) {
2524
2598
  super(props);
2525
- this.state = { error: null };
2599
+ this.state = { error: null, shownFor: props.resetKey };
2526
2600
  }
2527
2601
  static getDerivedStateFromError(error) {
2528
2602
  return { error };
2529
2603
  }
2604
+ /**
2605
+ * Clear a caught error when the tab changes, WITHOUT remounting the subtree.
2606
+ *
2607
+ * This boundary used to be keyed on the active tab, which cleared the error by throwing the
2608
+ * whole subtree away — and with it every table's sort, filter and exclusions, and `useReport`'s
2609
+ * state, which resets to `idle` and refetches. So the natural exploratory loop — sort
2610
+ * Acquisition by engagement, check Journey, come back — always cost the reader their work, on a
2611
+ * component that invests real care in preserving exactly that state across a RANGE change.
2612
+ *
2613
+ * A reset key does the one thing the mount key was there for and nothing else.
2614
+ */
2615
+ static getDerivedStateFromProps(props, state) {
2616
+ if (props.resetKey !== state.shownFor) return { error: null, shownFor: props.resetKey };
2617
+ return null;
2618
+ }
2530
2619
  componentDidCatch(error) {
2531
2620
  console.error("Visitor insights: panel render failed:", error.message);
2532
2621
  }
@@ -2546,6 +2635,7 @@ function VisitorInsightsTool(props) {
2546
2635
  const [range, setRange] = (0, import_react4.useState)("week");
2547
2636
  const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react4.useState)("week");
2548
2637
  const [custom, setCustom] = (0, import_react4.useState)(() => ({ start: isoDaysAgo(30), end: isoDaysAgo(0) }));
2638
+ const atPresent = custom.end >= isoDaysAgo(0);
2549
2639
  const [activePanel, setActivePanel] = (0, import_react4.useState)("overview");
2550
2640
  const active = PANELS.find((p) => p.id === activePanel) ?? PANELS[0];
2551
2641
  return (
@@ -2588,9 +2678,15 @@ function VisitorInsightsTool(props) {
2588
2678
  type: "button",
2589
2679
  style: inlineClear,
2590
2680
  "aria-label": "Shift the window forward by its own length",
2681
+ disabled: atPresent,
2682
+ title: atPresent ? "This window already ends today" : void 0,
2591
2683
  onClick: () => {
2592
2684
  const span = daysBetween(custom.start, custom.end);
2593
- setCustom({ start: shiftDays(custom.start, span), end: shiftDays(custom.end, span) });
2685
+ const today = isoDaysAgo(0);
2686
+ const end = shiftDays(custom.end, span);
2687
+ const allowed = end > today ? daysBetween(custom.end, today) : span;
2688
+ if (allowed <= 0) return;
2689
+ setCustom({ start: shiftDays(custom.start, allowed), end: shiftDays(custom.end, allowed) });
2594
2690
  },
2595
2691
  children: "later \u2192"
2596
2692
  }
@@ -2610,7 +2706,7 @@ function VisitorInsightsTool(props) {
2610
2706
  style: { position: "relative" },
2611
2707
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2612
2708
  active && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: active.blurb }),
2613
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2709
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelBoundary, { resetKey: activePanel, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2614
2710
  ReportPanel,
2615
2711
  {
2616
2712
  tabId: active?.id ?? "overview",
@@ -2624,7 +2720,7 @@ function VisitorInsightsTool(props) {
2624
2720
  setRange("custom");
2625
2721
  }
2626
2722
  }
2627
- ) }, activePanel)
2723
+ ) })
2628
2724
  ] })
2629
2725
  }
2630
2726
  )