@liiift-studio/sanity-visitor-insights 0.23.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';
@@ -562,4 +576,4 @@ interface VisitorInsightsPluginOptions {
562
576
  */
563
577
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
564
578
 
565
- 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';
@@ -562,4 +576,4 @@ interface VisitorInsightsPluginOptions {
562
576
  */
563
577
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
564
578
 
565
- 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 })
@@ -1053,6 +1069,13 @@ 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);
@@ -1060,22 +1083,34 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1060
1083
  const [brushed, setBrushed] = (0, import_react3.useState)(null);
1061
1084
  const [measured, setMeasured] = (0, import_react3.useState)(WIDTH);
1062
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
+ }, []);
1063
1091
  const dates = (0, import_react3.useMemo)(() => {
1064
1092
  const all = /* @__PURE__ */ new Set();
1065
1093
  for (const s of series) for (const p of s.points) all.add(p.date);
1066
1094
  return [...all].sort();
1067
1095
  }, [series]);
1068
1096
  const plotWidth = Math.max(120, measured - GUTTER - RIGHT_PAD);
1069
- import_react3.default.useEffect(() => {
1070
- const frame = frameRef.current;
1071
- if (!frame || typeof ResizeObserver === "undefined") return;
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;
1072
1107
  const observer = new ResizeObserver((entries) => {
1073
1108
  const width = entries[0]?.contentRect.width;
1074
- if (width && Math.abs(width - measured) > 1) setMeasured(Math.round(width));
1109
+ if (width !== void 0) apply(width);
1075
1110
  });
1076
1111
  observer.observe(frame);
1077
1112
  return () => observer.disconnect();
1078
- }, [measured]);
1113
+ }, [frameNode]);
1079
1114
  const height = TOP_PAD + series.length * ROW_HEIGHT + AXIS_HEIGHT;
1080
1115
  const x = (0, import_react3.useMemo)(() => {
1081
1116
  if (dates.length === 0) return null;
@@ -1086,12 +1121,9 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1086
1121
  const frame = frameRef.current;
1087
1122
  if (!frame || dates.length === 0) return null;
1088
1123
  const box = frame.getBoundingClientRect();
1089
- const ratio = (clientX - box.left) / box.width * WIDTH;
1090
- const clamped = Math.max(GUTTER, Math.min(GUTTER + plotWidth, ratio));
1091
- const index = Math.round((clamped - GUTTER) / plotWidth * (dates.length - 1));
1092
- return Math.max(0, Math.min(dates.length - 1, index));
1124
+ return dayIndexAt(clientX, box.left, box.width, measured, plotWidth, dates.length);
1093
1125
  },
1094
- [dates.length, plotWidth]
1126
+ [dates.length, plotWidth, measured]
1095
1127
  );
1096
1128
  const commit = (0, import_react3.useCallback)(
1097
1129
  (from, to) => {
@@ -1119,6 +1151,13 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1119
1151
  if (real.length === 0) return `${row.label}: no data`;
1120
1152
  const top = real.reduce((best, p) => p.value > best.value ? p : best, real[0]);
1121
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
+ }
1122
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`))}`;
1123
1162
  };
1124
1163
  const missed = series.flatMap((row) => {
@@ -1142,7 +1181,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1142
1181
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: frameStyle, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1143
1182
  "svg",
1144
1183
  {
1145
- ref: frameRef,
1184
+ ref: attachFrame,
1146
1185
  viewBox: `0 0 ${measured} ${height}`,
1147
1186
  onMouseMove: onMove,
1148
1187
  onMouseLeave: () => {
@@ -1217,9 +1256,10 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1217
1256
  const byDate = new Map(shortfallPoints.map((p) => [p.date, p.value]));
1218
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);
1219
1258
  const path = row.mark === "events" ? "" : lineGen(row.points) ?? "";
1220
- const slot = plotWidth / Math.max(1, dates.length);
1221
- const stemWidth = Math.max(1.5, Math.min(9, slot - 1));
1222
- const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value > 0) : [];
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) : [];
1223
1263
  const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
1224
1264
  const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints.filter((p) => p.value !== null)) ?? "" : "";
1225
1265
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
@@ -1233,17 +1273,33 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1233
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" }),
1234
1274
  gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: 0.48 }),
1235
1275
  shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
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
+ }),
1236
1292
  stems.map((p) => {
1237
1293
  const cx = at(p);
1238
- const top2 = y(p.value);
1239
- if (!Number.isFinite(cx) || !Number.isFinite(top2)) return null;
1294
+ const headY = y(p.value);
1295
+ if (!Number.isFinite(cx) || !Number.isFinite(headY)) return null;
1240
1296
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1241
1297
  "rect",
1242
1298
  {
1243
- x: cx - stemWidth / 2,
1244
- y: top2,
1299
+ x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1300
+ y: Math.min(headY, bottom),
1245
1301
  width: stemWidth,
1246
- height: Math.max(1.5, bottom - top2),
1302
+ height: Math.max(1.5, Math.abs(bottom - headY)),
1247
1303
  fill: "currentColor",
1248
1304
  opacity: 0.75
1249
1305
  },
@@ -1377,7 +1433,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1377
1433
  }
1378
1434
  ),
1379
1435
  series.map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
1380
- /* @__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" }),
1381
1437
  " ",
1382
1438
  row.label,
1383
1439
  " (",
@@ -1665,11 +1721,12 @@ function Verdict({ data, previous }) {
1665
1721
  const sends = data.campaigns?.length ?? 0;
1666
1722
  if (sends > 0) parts.push(`${sends} campaign${sends === 1 ? "" : "s"} sent`);
1667
1723
  const shortfall = data.shortfallRatio;
1668
- 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;
1669
1726
  if (broken) parts.push(broken);
1670
1727
  if (parts.length === 0) return null;
1671
- const checked = shortfall !== null && shortfall !== void 0;
1672
- 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}`;
1673
1730
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1674
1731
  import_sanity_ui_compat3.Card,
1675
1732
  {
@@ -1702,7 +1759,8 @@ function DataHealthPanel({ data, diagnostics }) {
1702
1759
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "How much GA4 is seeing" }),
1703
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: [
1704
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" }),
1705
- /* @__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." }),
1706
1764
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
1707
1765
  formatCount(estimate.observed),
1708
1766
  " of ",
@@ -2179,6 +2237,7 @@ var RANGES = [
2179
2237
  { key: "quarter", label: "Quarter", span: "the last 91 days" },
2180
2238
  { key: "year", label: "Year", span: "the last 365 days" }
2181
2239
  ];
2240
+ var GA4_ONLY_TABS = ["acquisition", "journey", "typeface-interest"];
2182
2241
  var PANELS = [
2183
2242
  { id: "overview", report: "measurement-health", label: "Overview", blurb: "Money, traffic and what moved this period" },
2184
2243
  { id: "acquisition", report: "acquisition", label: "Acquisition", blurb: "Where visitors come from" },
@@ -2363,6 +2422,17 @@ function PanelTabs({ value, onChange }) {
2363
2422
  );
2364
2423
  }) });
2365
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
+ }
2366
2436
  function ReportPanel({
2367
2437
  tabId,
2368
2438
  report,
@@ -2399,37 +2469,38 @@ function ReportPanel({
2399
2469
  !state.disabled && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", style: rangeButton(false), onClick: reload, children: "Try again" }) })
2400
2470
  ] }) }),
2401
2471
  state.status === "ready" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2402
- tabId === "overview" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OverviewPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data, onBrush }),
2403
- tabId === "data-health" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2404
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2405
- DataHealthPanel,
2406
- {
2407
- data: state.envelope.data,
2408
- diagnostics: diagnostics.status === "ready" ? diagnostics.envelope.data : void 0
2409
- }
2410
- ),
2411
- diagnostics.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: "Checking configuration\u2026" }),
2412
- 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: [
2413
- "Configuration checks could not run: ",
2414
- diagnostics.message
2415
- ] }) })
2416
- ] }),
2417
- tabId === "acquisition" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AcquisitionPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data }),
2418
- tabId === "journey" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(JourneyPanel, { data: state.envelope.data }),
2419
- tabId === "typeface-interest" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(TypefaceInterestPanel, { data: state.envelope.data }),
2472
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SourceStatusRow, { sources: state.envelope.sources }),
2420
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: [
2421
2474
  "These figures are the last ones that loaded. Refreshing them failed: ",
2422
2475
  state.revalidationError
2423
2476
  ] }) }),
2424
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SourceStatusRow, { sources: state.envelope.sources }),
2425
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(NoticeList, { notices: state.envelope.notices }),
2426
2477
  state.envelope.comparison && COMPARED_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2427
2478
  "Changes are against ",
2428
2479
  state.envelope.comparison.range.start,
2429
2480
  " to ",
2430
2481
  state.envelope.comparison.range.end,
2431
2482
  ", the equivalent window immediately before this one.",
2432
- 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." })
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 })
2433
2504
  ] }),
2434
2505
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2435
2506
  "Figures cover ",
@@ -2780,6 +2851,7 @@ var index_default = visitorInsights;
2780
2851
  formatMoney,
2781
2852
  formatPercent,
2782
2853
  isReportName,
2854
+ knownShortfall,
2783
2855
  ok,
2784
2856
  partial,
2785
2857
  previousRange,