@liiift-studio/sanity-visitor-insights 0.36.0 → 0.38.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
@@ -467,6 +467,20 @@ interface Series {
467
467
  * a site running at a flat 20% would show a full-height line and read as healthy.
468
468
  */
469
469
  domain?: [number, number];
470
+ /**
471
+ * The same series in the previous equivalent window, aligned by day OFFSET rather than by date.
472
+ *
473
+ * Drawn as a quiet ghost behind the row. Without it every row is an unanchored silhouette: a
474
+ * flat line at 60 and a flat line at 6,000 are the same picture, and "traffic looks lumpy" is an
475
+ * impression nobody can falsify. The comparison envelope is already fetched and already handed
476
+ * to the metric cards, so this costs a prop and no network.
477
+ *
478
+ * What it buys is bigger than a reference line. It turns the tool's most fragile asset — a dated
479
+ * alarm produced by a heuristic with a 21-day floor and a percentile threshold — from something
480
+ * the reader has to trust into something they can check: the coverage row's step reads against
481
+ * last period's flat line whether or not the detector fired.
482
+ */
483
+ comparison?: SeriesPoint[];
470
484
  points: SeriesPoint[];
471
485
  /**
472
486
  * What a lossier source saw of the same thing.
package/dist/index.d.ts CHANGED
@@ -467,6 +467,20 @@ interface Series {
467
467
  * a site running at a flat 20% would show a full-height line and read as healthy.
468
468
  */
469
469
  domain?: [number, number];
470
+ /**
471
+ * The same series in the previous equivalent window, aligned by day OFFSET rather than by date.
472
+ *
473
+ * Drawn as a quiet ghost behind the row. Without it every row is an unanchored silhouette: a
474
+ * flat line at 60 and a flat line at 6,000 are the same picture, and "traffic looks lumpy" is an
475
+ * impression nobody can falsify. The comparison envelope is already fetched and already handed
476
+ * to the metric cards, so this costs a prop and no network.
477
+ *
478
+ * What it buys is bigger than a reference line. It turns the tool's most fragile asset — a dated
479
+ * alarm produced by a heuristic with a 21-day floor and a percentile threshold — from something
480
+ * the reader has to trust into something they can check: the coverage row's step reads against
481
+ * last period's flat line whether or not the detector fired.
482
+ */
483
+ comparison?: SeriesPoint[];
470
484
  points: SeriesPoint[];
471
485
  /**
472
486
  * What a lossier source saw of the same thing.
package/dist/index.js CHANGED
@@ -491,6 +491,7 @@ function ProportionChart({ bars, format, totalLabel }) {
491
491
  ] })
492
492
  ] });
493
493
  }
494
+ var MIN_RATE_DENOMINATOR = 30;
494
495
  function FunnelChart({ stages, measurement }) {
495
496
  const [activeKey, setActiveKey] = (0, import_react2.useState)(null);
496
497
  const refs = (0, import_react2.useRef)([]);
@@ -535,8 +536,8 @@ function FunnelChart({ stages, measurement }) {
535
536
  ] }),
536
537
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: { ...barTrack, height: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...barFill, width: `${width}%` } }) }),
537
538
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
538
- index === 0 ? "entry step" : `${formatPercent(share, 1)} of ${stages[0]?.label.toLowerCase()}`,
539
- index > 1 && stage.conversionFromPrevious !== null && previous ? ` \xB7 ${formatPercent(stage.conversionFromPrevious, 1)} of ${previous.label.toLowerCase()}` : ""
539
+ index === 0 ? "entry step" : entry >= MIN_RATE_DENOMINATOR && stage.value >= MIN_RATE_DENOMINATOR ? `${formatPercent(share, 1)} of ${stages[0]?.label.toLowerCase()}` : "too few to give a rate",
540
+ index > 1 && stage.conversionFromPrevious !== null && previous ? (previous.value ?? 0) >= MIN_RATE_DENOMINATOR && stage.value >= MIN_RATE_DENOMINATOR ? ` \xB7 ${formatPercent(stage.conversionFromPrevious, 1)} of ${previous.label.toLowerCase()}` : ` \xB7 too few from ${previous.label.toLowerCase()} to give a rate` : ""
540
541
  ] })
541
542
  ]
542
543
  }
@@ -919,7 +920,7 @@ var import_d3_shape = require("d3-shape");
919
920
  var import_d3_array = require("d3-array");
920
921
  var import_jsx_runtime2 = require("react/jsx-runtime");
921
922
  var WIDTH = 760;
922
- var ROW_HEIGHT = 92;
923
+ var BASELINE_GUTTER = 18;
923
924
  var AXIS_HEIGHT = 26;
924
925
  var GUTTER = 52;
925
926
  var RIGHT_PAD = 12;
@@ -1012,6 +1013,8 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1012
1013
  return [...all].sort();
1013
1014
  }, [series]);
1014
1015
  const plotWidth = Math.max(120, measured - GUTTER - RIGHT_PAD);
1016
+ const bandHeight = Math.round(Math.max(58, Math.min(150, plotWidth / 7)));
1017
+ const rowHeight = bandHeight + LABEL_STRIP + BASELINE_GUTTER;
1015
1018
  import_react3.default.useLayoutEffect(() => {
1016
1019
  const frame = frameNode;
1017
1020
  if (!frame) return;
@@ -1029,7 +1032,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1029
1032
  observer.observe(frame);
1030
1033
  return () => observer.disconnect();
1031
1034
  }, [frameNode]);
1032
- const height = TOP_PAD + series.length * ROW_HEIGHT + AXIS_HEIGHT;
1035
+ const height = TOP_PAD + series.length * rowHeight + AXIS_HEIGHT;
1033
1036
  const x = (0, import_react3.useMemo)(() => {
1034
1037
  if (dates.length === 0) return null;
1035
1038
  return (0, import_d3_scale.scaleUtc)().domain([/* @__PURE__ */ new Date(`${dates[0]}T00:00:00Z`), /* @__PURE__ */ new Date(`${dates[dates.length - 1]}T00:00:00Z`)]).range([GUTTER, GUTTER + plotWidth]);
@@ -1216,17 +1219,17 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1216
1219
  x1: at,
1217
1220
  x2: at,
1218
1221
  y1: TOP_PAD,
1219
- y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1222
+ y2: height - AXIS_HEIGHT + 4,
1220
1223
  stroke: "currentColor",
1221
1224
  strokeWidth: 1,
1222
- opacity: 0.08
1225
+ opacity: 0.18
1223
1226
  },
1224
1227
  `grid-${date}`
1225
1228
  );
1226
1229
  }),
1227
1230
  series.map((row, rowIndex) => {
1228
- const top = TOP_PAD + rowIndex * ROW_HEIGHT;
1229
- const bottom = top + ROW_HEIGHT - 18;
1231
+ const top = TOP_PAD + rowIndex * rowHeight;
1232
+ const bottom = top + rowHeight - BASELINE_GUTTER;
1230
1233
  const plotTop = top + LABEL_STRIP;
1231
1234
  const values = row.points.map((p) => p.value).filter((v) => v !== null);
1232
1235
  const peak = (0, import_d3_array.max)(values) ?? 0;
@@ -1235,11 +1238,15 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1235
1238
  const zeroY = y(0);
1236
1239
  const at = (p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`));
1237
1240
  const defined = (p) => p.value !== null;
1238
- const lineGen = (0, import_d3_shape.line)().defined(defined).x(at).y((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
1241
+ const lineGen = (0, import_d3_shape.line)().defined(defined).x(at).y((p) => y(p.value)).curve(import_d3_shape.curveLinear);
1239
1242
  const shortfallPoints = row.shortfall?.points ?? [];
1240
1243
  const byDate = new Map(shortfallPoints.map((p) => [p.date, p.value]));
1241
- const gapGen = (0, import_d3_shape.area)().defined((p) => p.value !== null && byDate.get(p.date) != null).x(at).y0((p) => y(byDate.get(p.date))).y1((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
1244
+ const gapGen = (0, import_d3_shape.area)().defined((p) => p.value !== null && byDate.get(p.date) != null).x(at).y0((p) => y(byDate.get(p.date))).y1((p) => y(p.value)).curve(import_d3_shape.curveLinear);
1242
1245
  const path = row.mark === "events" ? "" : lineGen(row.points) ?? "";
1246
+ const ghost = row.comparison && row.comparison.length > 1 ? lineGen(row.comparison.slice(0, dates.length).map((point, i) => ({
1247
+ date: dates[i] ?? point.date,
1248
+ value: point.value
1249
+ }))) ?? "" : "";
1243
1250
  const pitch = plotWidth / Math.max(1, dates.length - 1);
1244
1251
  const stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch));
1245
1252
  const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value !== 0) : [];
@@ -1258,6 +1265,17 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1258
1265
  /* @__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) }),
1259
1266
  /* @__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) }),
1260
1267
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { clipPath: `url(#${clipId})`, children: [
1268
+ ghost && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1269
+ "path",
1270
+ {
1271
+ d: ghost,
1272
+ fill: "none",
1273
+ stroke: "currentColor",
1274
+ strokeWidth: 1,
1275
+ strokeDasharray: "2 3",
1276
+ opacity: 0.35
1277
+ }
1278
+ ),
1261
1279
  gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
1262
1280
  shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
1263
1281
  zeroes.map((p) => {
@@ -1317,7 +1335,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1317
1335
  x1: at,
1318
1336
  x2: at,
1319
1337
  y1: TOP_PAD - 6,
1320
- y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1338
+ y2: TOP_PAD + series.length * rowHeight - BASELINE_GUTTER,
1321
1339
  stroke: "currentColor",
1322
1340
  strokeWidth: 1.25,
1323
1341
  strokeDasharray: "4 3",
@@ -1334,7 +1352,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1334
1352
  x: GUTTER,
1335
1353
  y: TOP_PAD - 8,
1336
1354
  width: Math.max(0, x(/* @__PURE__ */ new Date(`${dates[brushed[0]]}T00:00:00Z`)) - GUTTER),
1337
- height: series.length * ROW_HEIGHT - 10,
1355
+ height: series.length * rowHeight - 10,
1338
1356
  fill: "var(--card-bg-color, #ffffff)",
1339
1357
  opacity: 0.72
1340
1358
  }
@@ -1345,7 +1363,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1345
1363
  x: x(/* @__PURE__ */ new Date(`${dates[brushed[1]]}T00:00:00Z`)),
1346
1364
  y: TOP_PAD - 8,
1347
1365
  width: Math.max(0, GUTTER + plotWidth - x(/* @__PURE__ */ new Date(`${dates[brushed[1]]}T00:00:00Z`))),
1348
- height: series.length * ROW_HEIGHT - 10,
1366
+ height: series.length * rowHeight - 10,
1349
1367
  fill: "var(--card-bg-color, #ffffff)",
1350
1368
  opacity: 0.72
1351
1369
  }
@@ -1357,7 +1375,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1357
1375
  x1: x(/* @__PURE__ */ new Date(`${hoveredDate}T00:00:00Z`)),
1358
1376
  x2: x(/* @__PURE__ */ new Date(`${hoveredDate}T00:00:00Z`)),
1359
1377
  y1: TOP_PAD - 6,
1360
- y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1378
+ y2: TOP_PAD + series.length * rowHeight - 18,
1361
1379
  stroke: "currentColor",
1362
1380
  strokeWidth: 1,
1363
1381
  opacity: 0.8
@@ -1443,7 +1461,7 @@ var readoutRow = {
1443
1461
  gap: 14,
1444
1462
  flexWrap: "wrap",
1445
1463
  alignItems: "baseline",
1446
- minHeight: 18
1464
+ minHeight: 54
1447
1465
  };
1448
1466
  var revealButton = {
1449
1467
  appearance: "none",
@@ -1577,6 +1595,8 @@ function OverviewPanel({ data, previous, onBrush }) {
1577
1595
  complete: true,
1578
1596
  unit: "count",
1579
1597
  points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.vercelPageviews })),
1598
+ // The same row last period, so the reader can see whether this shape is normal.
1599
+ comparison: (previous?.crossSource ?? []).map((d) => ({ date: d.date, value: d.vercelPageviews })),
1580
1600
  shortfall: {
1581
1601
  label: "Seen by GA4",
1582
1602
  source: "GA4",
@@ -1616,6 +1636,12 @@ function OverviewPanel({ data, previous, onBrush }) {
1616
1636
  points: (data.crossSource ?? []).map((d) => ({
1617
1637
  date: d.date,
1618
1638
  value: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? Math.min(1, d.ga4Pageviews / d.vercelPageviews) : null
1639
+ })),
1640
+ // The most valuable ghost in the chart: a collapse reads as a step away
1641
+ // from last period's flat line, whether or not the detector fired.
1642
+ comparison: (previous?.crossSource ?? []).map((d) => ({
1643
+ date: d.date,
1644
+ value: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? Math.min(1, d.ga4Pageviews / d.vercelPageviews) : null
1619
1645
  }))
1620
1646
  }] : [],
1621
1647
  ...data.crossSource?.some((d) => d.revenue !== null) ? [{