@liiift-studio/sanity-visitor-insights 0.28.0 → 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.js CHANGED
@@ -248,7 +248,8 @@ function Delta({ current, previous, riseIsGood = true, unit = "count" }) {
248
248
  const now = current;
249
249
  const before = previous;
250
250
  const absolute = now - before;
251
- 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) {
252
253
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: deltaStyle("flat"), children: "no change" });
253
254
  }
254
255
  const change = before === 0 ? null : absolute / Math.abs(before);
@@ -1087,11 +1088,49 @@ function dayIndexAt(clientX, left, boxWidth, viewBoxWidth, plotWidth, count) {
1087
1088
  const index = Math.round((clamped - GUTTER) / plotWidth * (count - 1));
1088
1089
  return Math.max(0, Math.min(count - 1, index));
1089
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
+ }
1090
1128
  function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1091
1129
  const [hoverIndex, setHoverIndex] = (0, import_react3.useState)(null);
1092
1130
  const [pinned, setPinned] = (0, import_react3.useState)(false);
1093
1131
  const [brushAnchor, setBrushAnchor] = (0, import_react3.useState)(null);
1094
1132
  const [brushed, setBrushed] = (0, import_react3.useState)(null);
1133
+ const [steppedByKeyboard, setSteppedByKeyboard] = (0, import_react3.useState)(false);
1095
1134
  const [tooShort, setTooShort] = (0, import_react3.useState)(false);
1096
1135
  const [measured, setMeasured] = (0, import_react3.useState)(WIDTH);
1097
1136
  const frameRef = (0, import_react3.useRef)(null);
@@ -1155,6 +1194,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1155
1194
  (event) => {
1156
1195
  const index = indexAt(event.clientX);
1157
1196
  if (index === null) return;
1197
+ setSteppedByKeyboard(false);
1158
1198
  setHoverIndex(index);
1159
1199
  if (brushAnchor !== null) {
1160
1200
  const from = Math.min(brushAnchor, index);
@@ -1212,6 +1252,17 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1212
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.`);
1213
1253
  }
1214
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
+ }
1215
1266
  const total = `${row.shortfall.source} missed ${formatValue(Math.round(gap), row.unit, currency)} ${row.label.toLowerCase()} over this period`;
1216
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}.`);
1217
1268
  return sentences;
@@ -1277,6 +1328,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1277
1328
  }
1278
1329
  if (next === null) return;
1279
1330
  event.preventDefault();
1331
+ setSteppedByKeyboard(true);
1280
1332
  setHoverIndex(next);
1281
1333
  if (event.shiftKey && onBrush) {
1282
1334
  const anchor = brushAnchor ?? current;
@@ -1305,7 +1357,9 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1305
1357
  const zeroes = row.mark === "events" && pitch >= 6 ? row.points.filter((p) => p.value === 0) : [];
1306
1358
  const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
1307
1359
  const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints) ?? "" : "";
1360
+ const clipId = `row-clip-${rowIndex}`;
1308
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) }) }) }),
1309
1363
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: bottom, y2: bottom, stroke: "currentColor", strokeWidth: 1, opacity: 0.45 }),
1310
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 }),
1311
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: [
@@ -1314,52 +1368,54 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1314
1368
  ] }),
1315
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) }),
1316
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) }),
1317
- gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
1318
- shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
1319
- zeroes.map((p) => {
1320
- const cx = at(p);
1321
- if (!Number.isFinite(cx)) return null;
1322
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1323
- "rect",
1324
- {
1325
- x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1326
- y: bottom - 3,
1327
- width: stemWidth,
1328
- height: 3,
1329
- fill: "currentColor",
1330
- opacity: 0.55
1331
- },
1332
- `zero-${p.date}`
1333
- );
1334
- }),
1335
- stems.map((p) => {
1336
- const cx = at(p);
1337
- const headY = y(p.value);
1338
- if (!Number.isFinite(cx) || !Number.isFinite(headY)) return null;
1339
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1340
- "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",
1341
1409
  {
1342
- x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1343
- y: Math.min(headY, bottom),
1344
- width: stemWidth,
1345
- height: Math.max(1.5, Math.abs(bottom - headY)),
1346
- fill: "currentColor",
1347
- opacity: 0.75
1348
- },
1349
- p.date
1350
- );
1351
- }),
1352
- path && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1353
- "path",
1354
- {
1355
- d: path,
1356
- fill: "none",
1357
- stroke: "currentColor",
1358
- strokeWidth: 2,
1359
- strokeDasharray: row.complete ? void 0 : "6 4",
1360
- opacity: row.complete ? 0.95 : 0.7
1361
- }
1362
- )
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
+ ] })
1363
1419
  ] }, row.key);
1364
1420
  }),
1365
1421
  markers.map((marker) => {
@@ -1418,6 +1474,25 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1418
1474
  opacity: 0.8
1419
1475
  }
1420
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
+ }),
1421
1496
  tickIndexes.map((index, position) => {
1422
1497
  const date = dates[index];
1423
1498
  if (!date) return null;
@@ -1438,7 +1513,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1438
1513
  ]
1439
1514
  }
1440
1515
  ) }),
1441
- /* @__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: [
1442
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" }),
1443
1518
  hoveredDate && series.map((row) => {
1444
1519
  const point = row.points.find((p) => p.date === hoveredDate);