@liiift-studio/sanity-visitor-insights 0.37.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
@@ -536,8 +536,8 @@ function FunnelChart({ stages, measurement }) {
536
536
  ] }),
537
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}%` } }) }),
538
538
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
539
- index === 0 ? "entry step" : entry >= 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 ? ` \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` : ""
541
541
  ] })
542
542
  ]
543
543
  }
@@ -920,7 +920,7 @@ var import_d3_shape = require("d3-shape");
920
920
  var import_d3_array = require("d3-array");
921
921
  var import_jsx_runtime2 = require("react/jsx-runtime");
922
922
  var WIDTH = 760;
923
- var ROW_HEIGHT = 92;
923
+ var BASELINE_GUTTER = 18;
924
924
  var AXIS_HEIGHT = 26;
925
925
  var GUTTER = 52;
926
926
  var RIGHT_PAD = 12;
@@ -1013,6 +1013,8 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1013
1013
  return [...all].sort();
1014
1014
  }, [series]);
1015
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;
1016
1018
  import_react3.default.useLayoutEffect(() => {
1017
1019
  const frame = frameNode;
1018
1020
  if (!frame) return;
@@ -1030,7 +1032,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1030
1032
  observer.observe(frame);
1031
1033
  return () => observer.disconnect();
1032
1034
  }, [frameNode]);
1033
- const height = TOP_PAD + series.length * ROW_HEIGHT + AXIS_HEIGHT;
1035
+ const height = TOP_PAD + series.length * rowHeight + AXIS_HEIGHT;
1034
1036
  const x = (0, import_react3.useMemo)(() => {
1035
1037
  if (dates.length === 0) return null;
1036
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]);
@@ -1217,17 +1219,17 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1217
1219
  x1: at,
1218
1220
  x2: at,
1219
1221
  y1: TOP_PAD,
1220
- y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1222
+ y2: height - AXIS_HEIGHT + 4,
1221
1223
  stroke: "currentColor",
1222
1224
  strokeWidth: 1,
1223
- opacity: 0.08
1225
+ opacity: 0.18
1224
1226
  },
1225
1227
  `grid-${date}`
1226
1228
  );
1227
1229
  }),
1228
1230
  series.map((row, rowIndex) => {
1229
- const top = TOP_PAD + rowIndex * ROW_HEIGHT;
1230
- const bottom = top + ROW_HEIGHT - 18;
1231
+ const top = TOP_PAD + rowIndex * rowHeight;
1232
+ const bottom = top + rowHeight - BASELINE_GUTTER;
1231
1233
  const plotTop = top + LABEL_STRIP;
1232
1234
  const values = row.points.map((p) => p.value).filter((v) => v !== null);
1233
1235
  const peak = (0, import_d3_array.max)(values) ?? 0;
@@ -1236,11 +1238,15 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1236
1238
  const zeroY = y(0);
1237
1239
  const at = (p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`));
1238
1240
  const defined = (p) => p.value !== null;
1239
- 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);
1240
1242
  const shortfallPoints = row.shortfall?.points ?? [];
1241
1243
  const byDate = new Map(shortfallPoints.map((p) => [p.date, p.value]));
1242
- 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);
1243
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
+ }))) ?? "" : "";
1244
1250
  const pitch = plotWidth / Math.max(1, dates.length - 1);
1245
1251
  const stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch));
1246
1252
  const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value !== 0) : [];
@@ -1259,6 +1265,17 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1259
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) }),
1260
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) }),
1261
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
+ ),
1262
1279
  gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
1263
1280
  shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
1264
1281
  zeroes.map((p) => {
@@ -1318,7 +1335,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1318
1335
  x1: at,
1319
1336
  x2: at,
1320
1337
  y1: TOP_PAD - 6,
1321
- y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1338
+ y2: TOP_PAD + series.length * rowHeight - BASELINE_GUTTER,
1322
1339
  stroke: "currentColor",
1323
1340
  strokeWidth: 1.25,
1324
1341
  strokeDasharray: "4 3",
@@ -1335,7 +1352,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1335
1352
  x: GUTTER,
1336
1353
  y: TOP_PAD - 8,
1337
1354
  width: Math.max(0, x(/* @__PURE__ */ new Date(`${dates[brushed[0]]}T00:00:00Z`)) - GUTTER),
1338
- height: series.length * ROW_HEIGHT - 10,
1355
+ height: series.length * rowHeight - 10,
1339
1356
  fill: "var(--card-bg-color, #ffffff)",
1340
1357
  opacity: 0.72
1341
1358
  }
@@ -1346,7 +1363,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1346
1363
  x: x(/* @__PURE__ */ new Date(`${dates[brushed[1]]}T00:00:00Z`)),
1347
1364
  y: TOP_PAD - 8,
1348
1365
  width: Math.max(0, GUTTER + plotWidth - x(/* @__PURE__ */ new Date(`${dates[brushed[1]]}T00:00:00Z`))),
1349
- height: series.length * ROW_HEIGHT - 10,
1366
+ height: series.length * rowHeight - 10,
1350
1367
  fill: "var(--card-bg-color, #ffffff)",
1351
1368
  opacity: 0.72
1352
1369
  }
@@ -1358,7 +1375,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1358
1375
  x1: x(/* @__PURE__ */ new Date(`${hoveredDate}T00:00:00Z`)),
1359
1376
  x2: x(/* @__PURE__ */ new Date(`${hoveredDate}T00:00:00Z`)),
1360
1377
  y1: TOP_PAD - 6,
1361
- y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1378
+ y2: TOP_PAD + series.length * rowHeight - 18,
1362
1379
  stroke: "currentColor",
1363
1380
  strokeWidth: 1,
1364
1381
  opacity: 0.8
@@ -1444,7 +1461,7 @@ var readoutRow = {
1444
1461
  gap: 14,
1445
1462
  flexWrap: "wrap",
1446
1463
  alignItems: "baseline",
1447
- minHeight: 18
1464
+ minHeight: 54
1448
1465
  };
1449
1466
  var revealButton = {
1450
1467
  appearance: "none",
@@ -1578,6 +1595,8 @@ function OverviewPanel({ data, previous, onBrush }) {
1578
1595
  complete: true,
1579
1596
  unit: "count",
1580
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 })),
1581
1600
  shortfall: {
1582
1601
  label: "Seen by GA4",
1583
1602
  source: "GA4",
@@ -1617,6 +1636,12 @@ function OverviewPanel({ data, previous, onBrush }) {
1617
1636
  points: (data.crossSource ?? []).map((d) => ({
1618
1637
  date: d.date,
1619
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
1620
1645
  }))
1621
1646
  }] : [],
1622
1647
  ...data.crossSource?.some((d) => d.revenue !== null) ? [{