@liiift-studio/sanity-visitor-insights 0.22.0 → 0.24.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
@@ -43,6 +43,20 @@ declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps):
43
43
  * extractable.
44
44
  */
45
45
 
46
+ /**
47
+ * What is known about GA4's coverage of this site and window, or null when nothing is.
48
+ *
49
+ * Null is a real answer and must render as nothing: a panel that cannot say how lossy its source is
50
+ * should not imply the source is fine.
51
+ *
52
+ * @param base - the site's API base URL, as passed to the tool
53
+ * @param range - the range name currently selected
54
+ * @param custom - the custom bounds, when the range is custom
55
+ */
56
+ declare function knownShortfall(base: string, range: string, custom?: {
57
+ start: string;
58
+ end: string;
59
+ }): number | null;
46
60
  /** Loading state for a report. */
47
61
  type ReportState<T> = {
48
62
  status: 'idle';
@@ -399,6 +413,16 @@ interface Series {
399
413
  /** Whether the line's source sees everything. Drives the stroke and the wording. */
400
414
  complete: boolean;
401
415
  unit: SeriesUnit;
416
+ /**
417
+ * How the row is drawn. Defaults to `line`.
418
+ *
419
+ * `events` is for a quantity that only exists on the days it happened — orders, revenue. Darden
420
+ * does seven orders a quarter, and a monotone curve through those seven points drew a smooth
421
+ * rise and fall across eighty-three days on which nothing occurred, with the curve's overshoot
422
+ * putting non-zero revenue on days that had none. A stem per day says what a sale is: a discrete
423
+ * event, on a date, of a size. Days with nothing draw nothing, which is the truth.
424
+ */
425
+ mark?: 'line' | 'events';
402
426
  points: SeriesPoint[];
403
427
  /**
404
428
  * What a lossier source saw of the same thing.
@@ -552,4 +576,4 @@ interface VisitorInsightsPluginOptions {
552
576
  */
553
577
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
554
578
 
555
- export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, visitorInsights as default, formatCount, formatMoney, formatPercent, useReport, visitorInsights };
579
+ export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, visitorInsights as default, formatCount, formatMoney, formatPercent, knownShortfall, useReport, visitorInsights };
package/dist/index.d.ts CHANGED
@@ -43,6 +43,20 @@ declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps):
43
43
  * extractable.
44
44
  */
45
45
 
46
+ /**
47
+ * What is known about GA4's coverage of this site and window, or null when nothing is.
48
+ *
49
+ * Null is a real answer and must render as nothing: a panel that cannot say how lossy its source is
50
+ * should not imply the source is fine.
51
+ *
52
+ * @param base - the site's API base URL, as passed to the tool
53
+ * @param range - the range name currently selected
54
+ * @param custom - the custom bounds, when the range is custom
55
+ */
56
+ declare function knownShortfall(base: string, range: string, custom?: {
57
+ start: string;
58
+ end: string;
59
+ }): number | null;
46
60
  /** Loading state for a report. */
47
61
  type ReportState<T> = {
48
62
  status: 'idle';
@@ -399,6 +413,16 @@ interface Series {
399
413
  /** Whether the line's source sees everything. Drives the stroke and the wording. */
400
414
  complete: boolean;
401
415
  unit: SeriesUnit;
416
+ /**
417
+ * How the row is drawn. Defaults to `line`.
418
+ *
419
+ * `events` is for a quantity that only exists on the days it happened — orders, revenue. Darden
420
+ * does seven orders a quarter, and a monotone curve through those seven points drew a smooth
421
+ * rise and fall across eighty-three days on which nothing occurred, with the curve's overshoot
422
+ * putting non-zero revenue on days that had none. A stem per day says what a sale is: a discrete
423
+ * event, on a date, of a size. Days with nothing draw nothing, which is the truth.
424
+ */
425
+ mark?: 'line' | 'events';
402
426
  points: SeriesPoint[];
403
427
  /**
404
428
  * What a lossier source saw of the same thing.
@@ -552,4 +576,4 @@ interface VisitorInsightsPluginOptions {
552
576
  */
553
577
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
554
578
 
555
- export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, visitorInsights as default, formatCount, formatMoney, formatPercent, useReport, visitorInsights };
579
+ export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, visitorInsights as default, formatCount, formatMoney, formatPercent, knownShortfall, useReport, visitorInsights };
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ __export(index_exports, {
57
57
  formatMoney: () => formatMoney,
58
58
  formatPercent: () => formatPercent,
59
59
  isReportName: () => isReportName,
60
+ knownShortfall: () => knownShortfall,
60
61
  ok: () => ok,
61
62
  partial: () => partial,
62
63
  previousRange: () => previousRange,
@@ -79,6 +80,19 @@ var import_sanity_ui_compat4 = require("@liiift-studio/sanity-ui-compat");
79
80
  var import_react = require("react");
80
81
  var import_sanity = require("sanity");
81
82
  var envelopeCache = /* @__PURE__ */ new Map();
83
+ var shortfallByWindow = /* @__PURE__ */ new Map();
84
+ function windowKey(range, custom) {
85
+ return range === "custom" && custom ? `${custom.start}..${custom.end}` : range;
86
+ }
87
+ function knownShortfall(base, range, custom) {
88
+ return shortfallByWindow.get(`${base}|${windowKey(range, custom)}`) ?? null;
89
+ }
90
+ function rememberShortfall(base, range, custom, data) {
91
+ const ratio = data?.shortfallRatio;
92
+ if (typeof ratio === "number" && Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {
93
+ shortfallByWindow.set(`${base}|${windowKey(range, custom)}`, ratio);
94
+ }
95
+ }
82
96
  function cacheKey(base, report, range, custom) {
83
97
  return [base, report, range, custom?.start ?? "", custom?.end ?? ""].join("|");
84
98
  }
@@ -140,6 +154,7 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
140
154
  const envelope = await response.json();
141
155
  if (requestIdRef.current !== requestId) return;
142
156
  envelopeCache.set(key, envelope);
157
+ rememberShortfall(apiBaseUrl, range, custom, envelope.data);
143
158
  setState({ status: "ready", envelope });
144
159
  } catch (e) {
145
160
  if (controller.signal.aborted || requestIdRef.current !== requestId) return;
@@ -827,7 +842,8 @@ function SortableTable({
827
842
  copyTimer.current = window.setTimeout(() => setCopied("idle"), 4e3);
828
843
  }
829
844
  };
830
- const hiddenCount = rows.length - visible.length;
845
+ const excludedInWindow = rows.filter((row) => excluded.has(rowKey(row))).length;
846
+ const hiddenCount = rows.length - visible.length - excludedInWindow;
831
847
  const excludedCount = excluded.size;
832
848
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
833
849
  (filterOn || exportName) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: tableControls, children: [
@@ -905,7 +921,7 @@ function SortableTable({
905
921
  ] }) }, column.key) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: column.numeric ? bodyCellNumeric : bodyCell, children: content }, column.key);
906
922
  }) }, key);
907
923
  }),
908
- ordered.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { colSpan: columns.length, style: bodyCell, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, muted: true, children: "No rows match this filter." }) }) })
924
+ ordered.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { colSpan: columns.length, style: bodyCell, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, muted: true, children: rows.length === 0 ? "Nothing to show for this period." : query.trim() || excludedInWindow > 0 ? "No rows match this filter." : "Nothing to show for this period." }) }) })
909
925
  ] })
910
926
  ] }) }),
911
927
  truncatedNote && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: truncatedNote })
@@ -1031,7 +1047,7 @@ var import_sanity_ui_compat5 = require("@liiift-studio/sanity-ui-compat");
1031
1047
  var import_sanity_ui_compat3 = require("@liiift-studio/sanity-ui-compat");
1032
1048
 
1033
1049
  // src/studio/CrossSourceTimeline.tsx
1034
- var import_react3 = require("react");
1050
+ var import_react3 = __toESM(require("react"));
1035
1051
  var import_sanity_ui_compat2 = require("@liiift-studio/sanity-ui-compat");
1036
1052
  var import_d3_scale = require("d3-scale");
1037
1053
  var import_d3_shape = require("d3-shape");
@@ -1053,18 +1069,48 @@ function formatValue(value, unit, currency) {
1053
1069
  function tickLabel(date) {
1054
1070
  return `${date.getUTCDate()} ${date.toLocaleString("en-GB", { month: "short", timeZone: "UTC" })}`;
1055
1071
  }
1072
+ function dayIndexAt(clientX, left, boxWidth, viewBoxWidth, plotWidth, count) {
1073
+ if (count === 0 || boxWidth <= 0 || plotWidth <= 0) return null;
1074
+ const ratio = (clientX - left) / boxWidth * viewBoxWidth;
1075
+ const clamped = Math.max(GUTTER, Math.min(GUTTER + plotWidth, ratio));
1076
+ const index = Math.round((clamped - GUTTER) / plotWidth * (count - 1));
1077
+ return Math.max(0, Math.min(count - 1, index));
1078
+ }
1056
1079
  function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1057
1080
  const [hoverIndex, setHoverIndex] = (0, import_react3.useState)(null);
1058
1081
  const [pinned, setPinned] = (0, import_react3.useState)(false);
1059
1082
  const [brushAnchor, setBrushAnchor] = (0, import_react3.useState)(null);
1060
1083
  const [brushed, setBrushed] = (0, import_react3.useState)(null);
1084
+ const [measured, setMeasured] = (0, import_react3.useState)(WIDTH);
1061
1085
  const frameRef = (0, import_react3.useRef)(null);
1086
+ const [frameNode, setFrameNode] = (0, import_react3.useState)(null);
1087
+ const attachFrame = (0, import_react3.useCallback)((node) => {
1088
+ frameRef.current = node;
1089
+ setFrameNode(node);
1090
+ }, []);
1062
1091
  const dates = (0, import_react3.useMemo)(() => {
1063
1092
  const all = /* @__PURE__ */ new Set();
1064
1093
  for (const s of series) for (const p of s.points) all.add(p.date);
1065
1094
  return [...all].sort();
1066
1095
  }, [series]);
1067
- const plotWidth = WIDTH - GUTTER - RIGHT_PAD;
1096
+ const plotWidth = Math.max(120, measured - GUTTER - RIGHT_PAD);
1097
+ import_react3.default.useLayoutEffect(() => {
1098
+ const frame = frameNode;
1099
+ if (!frame) return;
1100
+ const apply = (width) => {
1101
+ if (width <= 0) return;
1102
+ const next = Math.round(width);
1103
+ setMeasured((current) => Math.abs(next - current) > 1 ? next : current);
1104
+ };
1105
+ apply(frame.getBoundingClientRect().width);
1106
+ if (typeof ResizeObserver === "undefined") return;
1107
+ const observer = new ResizeObserver((entries) => {
1108
+ const width = entries[0]?.contentRect.width;
1109
+ if (width !== void 0) apply(width);
1110
+ });
1111
+ observer.observe(frame);
1112
+ return () => observer.disconnect();
1113
+ }, [frameNode]);
1068
1114
  const height = TOP_PAD + series.length * ROW_HEIGHT + AXIS_HEIGHT;
1069
1115
  const x = (0, import_react3.useMemo)(() => {
1070
1116
  if (dates.length === 0) return null;
@@ -1075,12 +1121,9 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1075
1121
  const frame = frameRef.current;
1076
1122
  if (!frame || dates.length === 0) return null;
1077
1123
  const box = frame.getBoundingClientRect();
1078
- const ratio = (clientX - box.left) / box.width * WIDTH;
1079
- const clamped = Math.max(GUTTER, Math.min(GUTTER + plotWidth, ratio));
1080
- const index = Math.round((clamped - GUTTER) / plotWidth * (dates.length - 1));
1081
- return Math.max(0, Math.min(dates.length - 1, index));
1124
+ return dayIndexAt(clientX, box.left, box.width, measured, plotWidth, dates.length);
1082
1125
  },
1083
- [dates.length, plotWidth]
1126
+ [dates.length, plotWidth, measured]
1084
1127
  );
1085
1128
  const commit = (0, import_react3.useCallback)(
1086
1129
  (from, to) => {
@@ -1108,6 +1151,13 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1108
1151
  if (real.length === 0) return `${row.label}: no data`;
1109
1152
  const top = real.reduce((best, p) => p.value > best.value ? p : best, real[0]);
1110
1153
  const total = real.reduce((sum, p) => sum + p.value, 0);
1154
+ if (row.mark === "events") {
1155
+ const active = real.filter((p) => p.value !== 0);
1156
+ if (active.length === 0) {
1157
+ return `${row.label} (${row.source}): nothing on any of the ${real.length} days measured`;
1158
+ }
1159
+ return `${row.label} (${row.source}): ${formatValue(total, row.unit, currency)} across ${active.length} of ${real.length} days, the largest ${formatValue(top.value, row.unit, currency)} on ${tickLabel(/* @__PURE__ */ new Date(`${top.date}T00:00:00Z`))}`;
1160
+ }
1111
1161
  return `${row.label} (${row.source}): ${formatValue(total, row.unit, currency)} in total, peaking at ${formatValue(top.value, row.unit, currency)} on ${tickLabel(/* @__PURE__ */ new Date(`${top.date}T00:00:00Z`))}`;
1112
1162
  };
1113
1163
  const missed = series.flatMap((row) => {
@@ -1131,8 +1181,8 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1131
1181
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: frameStyle, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1132
1182
  "svg",
1133
1183
  {
1134
- ref: frameRef,
1135
- viewBox: `0 0 ${WIDTH} ${height}`,
1184
+ ref: attachFrame,
1185
+ viewBox: `0 0 ${measured} ${height}`,
1136
1186
  onMouseMove: onMove,
1137
1187
  onMouseLeave: () => {
1138
1188
  setHoverIndex(null);
@@ -1160,7 +1210,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1160
1210
  setBrushed(null);
1161
1211
  commit(from, to);
1162
1212
  },
1163
- style: { width: "100%", height: "auto", display: "block", cursor: onBrush ? "col-resize" : "default" },
1213
+ style: { width: "100%", height, display: "block", cursor: onBrush ? "col-resize" : "default" },
1164
1214
  tabIndex: 0,
1165
1215
  role: "img",
1166
1216
  "aria-label": summary,
@@ -1205,7 +1255,11 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1205
1255
  const shortfallPoints = row.shortfall?.points ?? [];
1206
1256
  const byDate = new Map(shortfallPoints.map((p) => [p.date, p.value]));
1207
1257
  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);
1208
- const path = lineGen(row.points) ?? "";
1258
+ const path = row.mark === "events" ? "" : lineGen(row.points) ?? "";
1259
+ const pitch = plotWidth / Math.max(1, dates.length - 1);
1260
+ const stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch));
1261
+ const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value !== 0) : [];
1262
+ const zeroes = row.mark === "events" ? row.points.filter((p) => p.value === 0) : [];
1209
1263
  const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
1210
1264
  const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints.filter((p) => p.value !== null)) ?? "" : "";
1211
1265
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
@@ -1219,7 +1273,40 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1219
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" }),
1220
1274
  gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
1221
1275
  shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
1222
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1276
+ zeroes.map((p) => {
1277
+ const cx = at(p);
1278
+ if (!Number.isFinite(cx)) return null;
1279
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1280
+ "rect",
1281
+ {
1282
+ x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1283
+ y: bottom - 1,
1284
+ width: stemWidth,
1285
+ height: 1,
1286
+ fill: "currentColor",
1287
+ opacity: 0.3
1288
+ },
1289
+ `zero-${p.date}`
1290
+ );
1291
+ }),
1292
+ stems.map((p) => {
1293
+ const cx = at(p);
1294
+ const headY = y(p.value);
1295
+ if (!Number.isFinite(cx) || !Number.isFinite(headY)) return null;
1296
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1297
+ "rect",
1298
+ {
1299
+ x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1300
+ y: Math.min(headY, bottom),
1301
+ width: stemWidth,
1302
+ height: Math.max(1.5, Math.abs(bottom - headY)),
1303
+ fill: "currentColor",
1304
+ opacity: 0.75
1305
+ },
1306
+ p.date
1307
+ );
1308
+ }),
1309
+ path && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1223
1310
  "path",
1224
1311
  {
1225
1312
  d: path,
@@ -1346,7 +1433,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1346
1433
  }
1347
1434
  ),
1348
1435
  series.map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
1349
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { "aria-hidden": "true", children: row.complete ? "\u2500\u2500\u2500" : "\u254C\u254C\u254C" }),
1436
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { "aria-hidden": "true", children: row.mark === "events" ? "\u25AE\u25AE\u25AE" : row.complete ? "\u2500\u2500\u2500" : "\u254C\u254C\u254C" }),
1350
1437
  " ",
1351
1438
  row.label,
1352
1439
  " (",
@@ -1513,6 +1600,8 @@ function OverviewPanel({ data, previous, onBrush }) {
1513
1600
  source: "Sanity",
1514
1601
  complete: true,
1515
1602
  unit: "money",
1603
+ // A sale is an event on a date, not a level that varies day to day.
1604
+ mark: "events",
1516
1605
  points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.revenue }))
1517
1606
  }] : [{
1518
1607
  key: "orders",
@@ -1520,6 +1609,7 @@ function OverviewPanel({ data, previous, onBrush }) {
1520
1609
  source: "Sanity",
1521
1610
  complete: true,
1522
1611
  unit: "count",
1612
+ mark: "events",
1523
1613
  points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.orders }))
1524
1614
  }]
1525
1615
  ]
@@ -1631,11 +1721,12 @@ function Verdict({ data, previous }) {
1631
1721
  const sends = data.campaigns?.length ?? 0;
1632
1722
  if (sends > 0) parts.push(`${sends} campaign${sends === 1 ? "" : "s"} sent`);
1633
1723
  const shortfall = data.shortfallRatio;
1634
- const broken = shortfall !== null && shortfall !== void 0 && shortfall > 0.6 ? `GA4 is seeing ${formatPercent(1 - shortfall, 0)} of your traffic \u2014 treat its figures as broken` : data.capture?.discrepancy ? "measurement disagrees between sources" : null;
1724
+ const seeing = shortfall !== null && shortfall !== void 0 ? `GA4 is seeing ${formatPercent(1 - shortfall, 0)} of your traffic` : null;
1725
+ const broken = shortfall === null || shortfall === void 0 ? data.capture?.discrepancy ? "measurement disagrees between sources" : null : shortfall > 0.6 ? `${seeing} \u2014 treat its figures as broken` : shortfall > 0.35 ? `${seeing} \u2014 its figures are undercounts, not what happened` : data.capture?.discrepancy ? "measurement disagrees between sources" : null;
1635
1726
  if (broken) parts.push(broken);
1636
1727
  if (parts.length === 0) return null;
1637
- const checked = shortfall !== null && shortfall !== void 0;
1638
- const closing = broken ? "" : checked ? " \xB7 nothing broken" : "";
1728
+ const noticeable = shortfall !== null && shortfall !== void 0 && shortfall > 0.2;
1729
+ const closing = broken || !noticeable ? "" : ` \xB7 ${seeing}`;
1639
1730
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1640
1731
  import_sanity_ui_compat3.Card,
1641
1732
  {
@@ -1668,7 +1759,8 @@ function DataHealthPanel({ data, diagnostics }) {
1668
1759
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "How much GA4 is seeing" }),
1669
1760
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: cardGrid, children: (data.capture?.estimates ?? []).map((estimate) => /* @__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.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1670
1761
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: estimate.basis === "orders" ? "Measured against orders" : estimate.basis === "email" ? "Measured against email clicks" : "Measured against Vercel" }),
1671
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatPercent(Math.min(1, estimate.rate), 0) }),
1762
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatPercent(estimate.rate, 0) }),
1763
+ estimate.rate > 1.05 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, children: "Above 100%: GA4 is counting more than the source it is measured against. That is usually a tag firing twice, not extra traffic." }),
1672
1764
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
1673
1765
  formatCount(estimate.observed),
1674
1766
  " of ",
@@ -2145,6 +2237,7 @@ var RANGES = [
2145
2237
  { key: "quarter", label: "Quarter", span: "the last 91 days" },
2146
2238
  { key: "year", label: "Year", span: "the last 365 days" }
2147
2239
  ];
2240
+ var GA4_ONLY_TABS = ["acquisition", "journey", "typeface-interest"];
2148
2241
  var PANELS = [
2149
2242
  { id: "overview", report: "measurement-health", label: "Overview", blurb: "Money, traffic and what moved this period" },
2150
2243
  { id: "acquisition", report: "acquisition", label: "Acquisition", blurb: "Where visitors come from" },
@@ -2329,6 +2422,17 @@ function PanelTabs({ value, onChange }) {
2329
2422
  );
2330
2423
  }) });
2331
2424
  }
2425
+ function CoverageRibbon({ ratio }) {
2426
+ if (ratio === null || ratio < 0.2) return null;
2427
+ const seen = Math.round((1 - ratio) * 100);
2428
+ 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: [
2429
+ "Every figure on this tab comes from Google Analytics, which is seeing about ",
2430
+ seen,
2431
+ "% of this site\u2019s traffic in this window. Read them as at least ",
2432
+ seen === 0 ? "undercounts" : `${seen}% of the real numbers`,
2433
+ ", not as what happened. Data health explains why."
2434
+ ] }) });
2435
+ }
2332
2436
  function ReportPanel({
2333
2437
  tabId,
2334
2438
  report,
@@ -2352,10 +2456,6 @@ function ReportPanel({
2352
2456
  // felt state of the tool was "slightly unreadable", and muted text at 55% is under
2353
2457
  // 3:1 in both themes. The line says it; the figures stay legible.
2354
2458
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: "Updating\u2026" }),
2355
- state.status === "ready" && state.revalidationError && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 1, children: [
2356
- "These figures are the last ones that loaded. Refreshing them failed: ",
2357
- state.revalidationError
2358
- ] }) }),
2359
2459
  state.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Flex, { align: "center", gap: 3, padding: 4, children: [
2360
2460
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Spinner, { muted: true }),
2361
2461
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: "Loading\u2026" })
@@ -2370,33 +2470,38 @@ function ReportPanel({
2370
2470
  ] }) }),
2371
2471
  state.status === "ready" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2372
2472
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SourceStatusRow, { sources: state.envelope.sources }),
2373
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(NoticeList, { notices: state.envelope.notices }),
2473
+ state.revalidationError && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 1, children: [
2474
+ "These figures are the last ones that loaded. Refreshing them failed: ",
2475
+ state.revalidationError
2476
+ ] }) }),
2374
2477
  state.envelope.comparison && COMPARED_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2375
2478
  "Changes are against ",
2376
2479
  state.envelope.comparison.range.start,
2377
2480
  " to ",
2378
2481
  state.envelope.comparison.range.end,
2379
2482
  ", the equivalent window immediately before this one.",
2380
- state.envelope.comparison.provisional && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_jsx_runtime4.Fragment, { children: " This window\u2019s last days are still being processed by GA4, so changes read low." })
2381
- ] }),
2382
- tabId === "overview" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OverviewPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data, onBrush }),
2383
- tabId === "data-health" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2384
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2385
- DataHealthPanel,
2386
- {
2387
- data: state.envelope.data,
2388
- diagnostics: diagnostics.status === "ready" ? diagnostics.envelope.data : void 0
2389
- }
2390
- ),
2391
- diagnostics.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: "Checking configuration\u2026" }),
2392
- diagnostics.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 1, children: [
2393
- "Configuration checks could not run: ",
2394
- diagnostics.message
2395
- ] }) })
2483
+ state.envelope.comparison.provisional && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_jsx_runtime4.Fragment, { children: " This window\u2019s last days are still being processed by GA4, so changes read low." }),
2484
+ GA4_ONLY_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CoverageRibbon, { ratio: knownShortfall(apiBaseUrl, range, range === "custom" ? custom : void 0) }),
2485
+ tabId === "overview" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OverviewPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data, onBrush }),
2486
+ tabId === "data-health" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2487
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2488
+ DataHealthPanel,
2489
+ {
2490
+ data: state.envelope.data,
2491
+ diagnostics: diagnostics.status === "ready" ? diagnostics.envelope.data : void 0
2492
+ }
2493
+ ),
2494
+ diagnostics.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: "Checking configuration\u2026" }),
2495
+ diagnostics.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 1, children: [
2496
+ "Configuration checks could not run: ",
2497
+ diagnostics.message
2498
+ ] }) })
2499
+ ] }),
2500
+ tabId === "acquisition" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AcquisitionPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data }),
2501
+ tabId === "journey" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(JourneyPanel, { data: state.envelope.data }),
2502
+ tabId === "typeface-interest" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(TypefaceInterestPanel, { data: state.envelope.data }),
2503
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(NoticeList, { notices: state.envelope.notices })
2396
2504
  ] }),
2397
- tabId === "acquisition" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AcquisitionPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data }),
2398
- tabId === "journey" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(JourneyPanel, { data: state.envelope.data }),
2399
- tabId === "typeface-interest" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(TypefaceInterestPanel, { data: state.envelope.data }),
2400
2505
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2401
2506
  "Figures cover ",
2402
2507
  state.envelope.range.start,
@@ -2746,6 +2851,7 @@ var index_default = visitorInsights;
2746
2851
  formatMoney,
2747
2852
  formatPercent,
2748
2853
  isReportName,
2854
+ knownShortfall,
2749
2855
  ok,
2750
2856
  partial,
2751
2857
  previousRange,