@liiift-studio/sanity-visitor-insights 0.25.0 → 0.26.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
@@ -3,37 +3,6 @@ import React from 'react';
3
3
  import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-BUZuS1c-.mjs';
4
4
  export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-BUZuS1c-.mjs';
5
5
 
6
- /**
7
- * The Visitor Insights Studio tool.
8
- *
9
- * One range control drives every panel, implemented once here as a proper radiogroup with
10
- * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up
11
- * inconsistent between them.
12
- */
13
-
14
- /** Options supplied by the plugin config, carried on the Sanity tool definition. */
15
- interface VisitorInsightsToolProps {
16
- apiBaseUrl: string;
17
- siteLabel: string;
18
- }
19
- /**
20
- * What Sanity actually hands a tool component.
21
- *
22
- * Sanity does NOT spread a tool's `options` onto its component's props — it passes the whole tool
23
- * definition as `tool`, with the options nested inside. Destructuring `apiBaseUrl` straight off
24
- * props therefore yields undefined, and the first thing useReport does with it is call .replace(),
25
- * so every panel failed with "Cannot read properties of undefined (reading 'replace')".
26
- *
27
- * Both shapes are accepted: the nested one because that is what the Studio passes, and the flat one
28
- * because the component is exported for direct use and is mounted that way in tests.
29
- */
30
- interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolProps> {
31
- tool?: {
32
- options?: Partial<VisitorInsightsToolProps>;
33
- };
34
- }
35
- declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
36
-
37
6
  /**
38
7
  * Data hook for the Studio panels.
39
8
  *
@@ -100,6 +69,37 @@ declare function useReport<T>({ apiBaseUrl, report, range, custom, enabled }: Us
100
69
  reload: () => void;
101
70
  };
102
71
 
72
+ /**
73
+ * The Visitor Insights Studio tool.
74
+ *
75
+ * One range control drives every panel, implemented once here as a proper radiogroup with
76
+ * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up
77
+ * inconsistent between them.
78
+ */
79
+
80
+ /** Options supplied by the plugin config, carried on the Sanity tool definition. */
81
+ interface VisitorInsightsToolProps {
82
+ apiBaseUrl: string;
83
+ siteLabel: string;
84
+ }
85
+ /**
86
+ * What Sanity actually hands a tool component.
87
+ *
88
+ * Sanity does NOT spread a tool's `options` onto its component's props — it passes the whole tool
89
+ * definition as `tool`, with the options nested inside. Destructuring `apiBaseUrl` straight off
90
+ * props therefore yields undefined, and the first thing useReport does with it is call .replace(),
91
+ * so every panel failed with "Cannot read properties of undefined (reading 'replace')".
92
+ *
93
+ * Both shapes are accepted: the nested one because that is what the Studio passes, and the flat one
94
+ * because the component is exported for direct use and is mounted that way in tests.
95
+ */
96
+ interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolProps> {
97
+ tool?: {
98
+ options?: Partial<VisitorInsightsToolProps>;
99
+ };
100
+ }
101
+ declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
102
+
103
103
  /**
104
104
  * Shared renderers for metric values and comparison bars.
105
105
  *
@@ -199,6 +199,10 @@ interface ChartDataProps<Row> {
199
199
  columns: Array<SortColumn<Row>>;
200
200
  rowKey: (row: Row) => string;
201
201
  exportName?: string;
202
+ /** A line above the table, for anything a column header cannot carry. */
203
+ note?: string;
204
+ /** Which column to filter on, if the table should offer a filter box. */
205
+ filterOn?: (row: Row) => string;
202
206
  }
203
207
  /**
204
208
  * The figures behind a chart, as a table, collapsed by default.
@@ -212,7 +216,7 @@ interface ChartDataProps<Row> {
212
216
  * axis type at any size; and anyone who wants these numbers in a spreadsheet, since the table
213
217
  * brings sorting and CSV copy with it. Collapsed, so it costs a sighted reader one line.
214
218
  */
215
- declare function ChartData<Row>({ label, rows, columns, rowKey, exportName }: ChartDataProps<Row>): React.ReactElement | null;
219
+ declare function ChartData<Row>({ label, rows, columns, rowKey, exportName, note, filterOn }: ChartDataProps<Row>): React.ReactElement | null;
216
220
  /** One bar of a proportion chart. */
217
221
  interface ProportionBar {
218
222
  key: string;
package/dist/index.d.ts CHANGED
@@ -3,37 +3,6 @@ import React from 'react';
3
3
  import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-BUZuS1c-.js';
4
4
  export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-BUZuS1c-.js';
5
5
 
6
- /**
7
- * The Visitor Insights Studio tool.
8
- *
9
- * One range control drives every panel, implemented once here as a proper radiogroup with
10
- * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up
11
- * inconsistent between them.
12
- */
13
-
14
- /** Options supplied by the plugin config, carried on the Sanity tool definition. */
15
- interface VisitorInsightsToolProps {
16
- apiBaseUrl: string;
17
- siteLabel: string;
18
- }
19
- /**
20
- * What Sanity actually hands a tool component.
21
- *
22
- * Sanity does NOT spread a tool's `options` onto its component's props — it passes the whole tool
23
- * definition as `tool`, with the options nested inside. Destructuring `apiBaseUrl` straight off
24
- * props therefore yields undefined, and the first thing useReport does with it is call .replace(),
25
- * so every panel failed with "Cannot read properties of undefined (reading 'replace')".
26
- *
27
- * Both shapes are accepted: the nested one because that is what the Studio passes, and the flat one
28
- * because the component is exported for direct use and is mounted that way in tests.
29
- */
30
- interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolProps> {
31
- tool?: {
32
- options?: Partial<VisitorInsightsToolProps>;
33
- };
34
- }
35
- declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
36
-
37
6
  /**
38
7
  * Data hook for the Studio panels.
39
8
  *
@@ -100,6 +69,37 @@ declare function useReport<T>({ apiBaseUrl, report, range, custom, enabled }: Us
100
69
  reload: () => void;
101
70
  };
102
71
 
72
+ /**
73
+ * The Visitor Insights Studio tool.
74
+ *
75
+ * One range control drives every panel, implemented once here as a proper radiogroup with
76
+ * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up
77
+ * inconsistent between them.
78
+ */
79
+
80
+ /** Options supplied by the plugin config, carried on the Sanity tool definition. */
81
+ interface VisitorInsightsToolProps {
82
+ apiBaseUrl: string;
83
+ siteLabel: string;
84
+ }
85
+ /**
86
+ * What Sanity actually hands a tool component.
87
+ *
88
+ * Sanity does NOT spread a tool's `options` onto its component's props — it passes the whole tool
89
+ * definition as `tool`, with the options nested inside. Destructuring `apiBaseUrl` straight off
90
+ * props therefore yields undefined, and the first thing useReport does with it is call .replace(),
91
+ * so every panel failed with "Cannot read properties of undefined (reading 'replace')".
92
+ *
93
+ * Both shapes are accepted: the nested one because that is what the Studio passes, and the flat one
94
+ * because the component is exported for direct use and is mounted that way in tests.
95
+ */
96
+ interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolProps> {
97
+ tool?: {
98
+ options?: Partial<VisitorInsightsToolProps>;
99
+ };
100
+ }
101
+ declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
102
+
103
103
  /**
104
104
  * Shared renderers for metric values and comparison bars.
105
105
  *
@@ -199,6 +199,10 @@ interface ChartDataProps<Row> {
199
199
  columns: Array<SortColumn<Row>>;
200
200
  rowKey: (row: Row) => string;
201
201
  exportName?: string;
202
+ /** A line above the table, for anything a column header cannot carry. */
203
+ note?: string;
204
+ /** Which column to filter on, if the table should offer a filter box. */
205
+ filterOn?: (row: Row) => string;
202
206
  }
203
207
  /**
204
208
  * The figures behind a chart, as a table, collapsed by default.
@@ -212,7 +216,7 @@ interface ChartDataProps<Row> {
212
216
  * axis type at any size; and anyone who wants these numbers in a spreadsheet, since the table
213
217
  * brings sorting and CSV copy with it. Collapsed, so it costs a sighted reader one line.
214
218
  */
215
- declare function ChartData<Row>({ label, rows, columns, rowKey, exportName }: ChartDataProps<Row>): React.ReactElement | null;
219
+ declare function ChartData<Row>({ label, rows, columns, rowKey, exportName, note, filterOn }: ChartDataProps<Row>): React.ReactElement | null;
216
220
  /** One bar of a proportion chart. */
217
221
  interface ProportionBar {
218
222
  key: string;
package/dist/index.js CHANGED
@@ -365,20 +365,24 @@ function ComparisonBar({ label, metric, max, outOf }) {
365
365
  outOf && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: outOf })
366
366
  ] });
367
367
  }
368
- function ChartData({ label, rows, columns, rowKey, exportName }) {
368
+ function ChartData({ label, rows, columns, rowKey, exportName, note, filterOn }) {
369
369
  if (rows.length === 0) return null;
370
370
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("details", { style: disclosureBlock, children: [
371
371
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("summary", { style: disclosureSummary, children: label }),
372
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { marginTop: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
373
- SortableTable,
374
- {
375
- caption: label,
376
- rows,
377
- columns,
378
- rowKey,
379
- exportName
380
- }
381
- ) })
372
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 10 }, children: [
373
+ note && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, style: { marginBottom: 8, display: "block" }, children: note }),
374
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
375
+ SortableTable,
376
+ {
377
+ caption: label,
378
+ rows,
379
+ columns,
380
+ rowKey,
381
+ exportName,
382
+ filterOn
383
+ }
384
+ )
385
+ ] })
382
386
  ] });
383
387
  }
384
388
  var disclosureBlock = { width: "100%" };
@@ -1054,7 +1058,7 @@ var import_d3_shape = require("d3-shape");
1054
1058
  var import_d3_array = require("d3-array");
1055
1059
  var import_jsx_runtime2 = require("react/jsx-runtime");
1056
1060
  var WIDTH = 760;
1057
- var ROW_HEIGHT = 74;
1061
+ var ROW_HEIGHT = 92;
1058
1062
  var AXIS_HEIGHT = 26;
1059
1063
  var GUTTER = 52;
1060
1064
  var RIGHT_PAD = 12;
@@ -1131,7 +1135,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1131
1135
  (from, to) => {
1132
1136
  if (!onBrush) return;
1133
1137
  if (to - from < MIN_BRUSH_DAYS - 1) {
1134
- setTooShort(true);
1138
+ setTooShort(to > from);
1135
1139
  return;
1136
1140
  }
1137
1141
  setTooShort(false);
@@ -1146,12 +1150,14 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1146
1150
  const index = indexAt(event.clientX);
1147
1151
  if (index === null) return;
1148
1152
  setHoverIndex(index);
1149
- if (brushAnchor !== null) setBrushed([Math.min(brushAnchor, index), Math.max(brushAnchor, index)]);
1153
+ if (brushAnchor !== null) {
1154
+ const from = Math.min(brushAnchor, index);
1155
+ const to = Math.max(brushAnchor, index);
1156
+ setBrushed((current) => current && current[0] === from && current[1] === to ? current : [from, to]);
1157
+ }
1150
1158
  },
1151
1159
  [indexAt, brushAnchor]
1152
1160
  );
1153
- if (dates.length < 3 || series.length === 0 || !x) return null;
1154
- const hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null;
1155
1161
  const tickIndexes = (0, import_react3.useMemo)(() => {
1156
1162
  if (dates.length === 0) return [];
1157
1163
  if (dates.length === 1) return [0];
@@ -1160,6 +1166,8 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1160
1166
  const indexes = Array.from({ length: affordable }, (_, i) => Math.round(i * step));
1161
1167
  return [...new Set(indexes)];
1162
1168
  }, [dates.length, plotWidth]);
1169
+ if (dates.length < 3 || series.length === 0 || !x) return null;
1170
+ const hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null;
1163
1171
  const revealed = pinned || hoverIndex !== null;
1164
1172
  const shapeOf = (row) => {
1165
1173
  const real = row.points.filter((p) => p.value !== null);
@@ -1193,11 +1201,14 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1193
1201
  const ratio = point.value > 0 ? 1 - saw / point.value : 0;
1194
1202
  if (point.value >= floor && (!worst || ratio > worst.ratio)) worst = { date: point.date, ratio };
1195
1203
  }
1196
- if (excess > gap) {
1197
- return [`${row.shortfall.source} counted ${formatCount(Math.round(excess - gap))} MORE ${row.label.toLowerCase()} than ${row.source} over this period, which usually means a tag firing twice rather than extra traffic.`];
1204
+ const sentences = [];
1205
+ if (excess > 0) {
1206
+ 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.`);
1198
1207
  }
1199
- if (gap <= 0 || !worst) return [];
1200
- return [`${row.shortfall.source} missed ${formatCount(Math.round(gap))} ${row.label.toLowerCase()} over this period; the gap was widest on ${tickLabel(/* @__PURE__ */ new Date(`${worst.date}T00:00:00Z`))} at ${formatPercent(worst.ratio, 0)}.`];
1208
+ if (gap <= 0) return sentences;
1209
+ const total = `${row.shortfall.source} missed ${formatValue(Math.round(gap), row.unit, currency)} ${row.label.toLowerCase()} over this period`;
1210
+ 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}.`);
1211
+ return sentences;
1201
1212
  });
1202
1213
  const summary = `${dates.length} days from ${tickLabel(/* @__PURE__ */ new Date(`${dates[0]}T00:00:00Z`))} to ${tickLabel(/* @__PURE__ */ new Date(`${dates[dates.length - 1]}T00:00:00Z`))}. ` + series.map(shapeOf).join(". ") + ". " + missed.join(" ") + (markers.length > 0 ? ` ${markers.length} campaign send${markers.length === 1 ? "" : "s"} marked.` : "");
1203
1214
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
@@ -1211,6 +1222,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1211
1222
  setHoverIndex(null);
1212
1223
  setBrushAnchor(null);
1213
1224
  setBrushed(null);
1225
+ setTooShort(false);
1214
1226
  },
1215
1227
  onPointerDown: (event) => {
1216
1228
  if (!onBrush) return;
@@ -1286,7 +1298,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1286
1298
  const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value !== 0) : [];
1287
1299
  const zeroes = row.mark === "events" ? row.points.filter((p) => p.value === 0) : [];
1288
1300
  const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
1289
- const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints.filter((p) => p.value !== null)) ?? "" : "";
1301
+ const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints) ?? "" : "";
1290
1302
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
1291
1303
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: bottom, y2: bottom, stroke: "currentColor", strokeWidth: 1, opacity: 0.45 }),
1292
1304
  /* @__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 }),
@@ -1305,11 +1317,11 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1305
1317
  "rect",
1306
1318
  {
1307
1319
  x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1308
- y: bottom - 1,
1320
+ y: bottom - 3,
1309
1321
  width: stemWidth,
1310
- height: 1,
1322
+ height: 3,
1311
1323
  fill: "currentColor",
1312
- opacity: 0.3
1324
+ opacity: 0.55
1313
1325
  },
1314
1326
  `zero-${p.date}`
1315
1327
  );
@@ -1660,10 +1672,12 @@ function OverviewPanel({ data, previous, onBrush }) {
1660
1672
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1661
1673
  ChartData,
1662
1674
  {
1675
+ note: "A negative miss means GA4 counted MORE than Vercel that day \u2014 usually a tag firing twice, not extra traffic.",
1663
1676
  label: "Show these figures as a table",
1664
1677
  rows: data.crossSource ?? [],
1665
1678
  rowKey: (d) => d.date,
1666
1679
  exportName: "timeline",
1680
+ filterOn: (d) => d.date,
1667
1681
  columns: [
1668
1682
  { key: "date", label: "Date", sortValue: (d) => d.date, render: (d) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: d.date }) },
1669
1683
  {
@@ -1695,7 +1709,6 @@ function OverviewPanel({ data, previous, onBrush }) {
1695
1709
  label: "Missed by GA4",
1696
1710
  numeric: true,
1697
1711
  sortValue: (d) => gapOf(d),
1698
- exportValue: (d) => gapOf(d),
1699
1712
  render: (d) => {
1700
1713
  const gap = gapOf(d);
1701
1714
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: gap === null ? "\u2014" : formatCount(gap) });
@@ -1712,7 +1725,13 @@ function OverviewPanel({ data, previous, onBrush }) {
1712
1725
  label: "GA4 coverage",
1713
1726
  numeric: true,
1714
1727
  sortValue: (d) => coverageOf(d),
1715
- exportValue: (d) => coverageOf(d),
1728
+ // The FORMATTED value, matching the cell. Passing the raw ratio through
1729
+ // shipped 0.06666666666666667 into a CSV column whose cell reads "7%" —
1730
+ // verbatim the case exportValue exists to prevent.
1731
+ exportValue: (d) => {
1732
+ const coverage = coverageOf(d);
1733
+ return coverage === null ? null : formatPercent(coverage, 0);
1734
+ },
1716
1735
  render: (d) => {
1717
1736
  const coverage = coverageOf(d);
1718
1737
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: coverage === null ? "\u2014" : formatPercent(coverage, 0) });
@@ -2507,6 +2526,60 @@ function CoverageRibbon({ ratio }) {
2507
2526
  ", not as what happened. Data health explains why."
2508
2527
  ] }) });
2509
2528
  }
2529
+ function ReadyReport({
2530
+ envelope,
2531
+ tabId,
2532
+ apiBaseUrl,
2533
+ range,
2534
+ custom,
2535
+ revalidationError,
2536
+ diagnostics,
2537
+ onBrush
2538
+ }) {
2539
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2540
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SourceStatusRow, { sources: envelope.sources }),
2541
+ 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: [
2542
+ "These figures are the last ones that loaded. Refreshing them failed: ",
2543
+ revalidationError
2544
+ ] }) }),
2545
+ envelope.comparison && COMPARED_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2546
+ "Changes are against ",
2547
+ envelope.comparison.range.start,
2548
+ " to ",
2549
+ envelope.comparison.range.end,
2550
+ ", the equivalent window immediately before this one.",
2551
+ 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." })
2552
+ ] }),
2553
+ GA4_ONLY_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CoverageRibbon, { ratio: knownShortfall(apiBaseUrl, range, range === "custom" ? custom : void 0) }),
2554
+ tabId === "overview" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OverviewPanel, { data: envelope.data, previous: envelope.comparison?.data, onBrush }),
2555
+ tabId === "data-health" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2556
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2557
+ DataHealthPanel,
2558
+ {
2559
+ data: envelope.data,
2560
+ diagnostics: diagnostics.status === "ready" ? diagnostics.envelope.data : void 0
2561
+ }
2562
+ ),
2563
+ diagnostics.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: "Checking configuration\u2026" }),
2564
+ 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: [
2565
+ "Configuration checks could not run: ",
2566
+ diagnostics.message
2567
+ ] }) })
2568
+ ] }),
2569
+ tabId === "acquisition" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AcquisitionPanel, { data: envelope.data, previous: envelope.comparison?.data }),
2570
+ tabId === "journey" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(JourneyPanel, { data: envelope.data }),
2571
+ tabId === "typeface-interest" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(TypefaceInterestPanel, { data: envelope.data }),
2572
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(NoticeList, { notices: envelope.notices }),
2573
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2574
+ "Figures cover ",
2575
+ envelope.range.start,
2576
+ " to ",
2577
+ envelope.range.end,
2578
+ ", in ",
2579
+ envelope.range.timezone
2580
+ ] })
2581
+ ] });
2582
+ }
2510
2583
  function ReportPanel({
2511
2584
  tabId,
2512
2585
  report,
@@ -2542,49 +2615,19 @@ function ReportPanel({
2542
2615
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: state.disabled, children: state.message }),
2543
2616
  !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" }) })
2544
2617
  ] }) }),
2545
- state.status === "ready" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2546
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SourceStatusRow, { sources: state.envelope.sources }),
2547
- 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: [
2548
- "These figures are the last ones that loaded. Refreshing them failed: ",
2549
- state.revalidationError
2550
- ] }) }),
2551
- state.envelope.comparison && COMPARED_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2552
- "Changes are against ",
2553
- state.envelope.comparison.range.start,
2554
- " to ",
2555
- state.envelope.comparison.range.end,
2556
- ", the equivalent window immediately before this one.",
2557
- 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." }),
2558
- GA4_ONLY_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CoverageRibbon, { ratio: knownShortfall(apiBaseUrl, range, range === "custom" ? custom : void 0) }),
2559
- tabId === "overview" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OverviewPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data, onBrush }),
2560
- tabId === "data-health" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2561
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2562
- DataHealthPanel,
2563
- {
2564
- data: state.envelope.data,
2565
- diagnostics: diagnostics.status === "ready" ? diagnostics.envelope.data : void 0
2566
- }
2567
- ),
2568
- diagnostics.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: "Checking configuration\u2026" }),
2569
- 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: [
2570
- "Configuration checks could not run: ",
2571
- diagnostics.message
2572
- ] }) })
2573
- ] }),
2574
- tabId === "acquisition" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AcquisitionPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data }),
2575
- tabId === "journey" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(JourneyPanel, { data: state.envelope.data }),
2576
- tabId === "typeface-interest" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(TypefaceInterestPanel, { data: state.envelope.data }),
2577
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(NoticeList, { notices: state.envelope.notices })
2578
- ] }),
2579
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2580
- "Figures cover ",
2581
- state.envelope.range.start,
2582
- " to ",
2583
- state.envelope.range.end,
2584
- ", in ",
2585
- state.envelope.range.timezone
2586
- ] })
2587
- ] })
2618
+ state.status === "ready" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2619
+ ReadyReport,
2620
+ {
2621
+ envelope: state.envelope,
2622
+ tabId,
2623
+ apiBaseUrl,
2624
+ range,
2625
+ custom,
2626
+ revalidationError: state.revalidationError ?? null,
2627
+ diagnostics,
2628
+ onBrush
2629
+ }
2630
+ )
2588
2631
  ] });
2589
2632
  }
2590
2633
  function isoDaysAgo(days) {
@@ -2676,15 +2719,15 @@ function VisitorInsightsTool(props) {
2676
2719
  "button",
2677
2720
  {
2678
2721
  type: "button",
2679
- style: inlineClear,
2680
2722
  "aria-label": "Shift the window forward by its own length",
2681
2723
  disabled: atPresent,
2724
+ style: atPresent ? { ...inlineClear, opacity: 0.35, cursor: "not-allowed" } : inlineClear,
2682
2725
  title: atPresent ? "This window already ends today" : void 0,
2683
2726
  onClick: () => {
2684
2727
  const span = daysBetween(custom.start, custom.end);
2685
2728
  const today = isoDaysAgo(0);
2686
- const end = shiftDays(custom.end, span);
2687
- const allowed = end > today ? daysBetween(custom.end, today) : span;
2729
+ const room = daysBetween(custom.end, today) - 1;
2730
+ const allowed = Math.min(span, room);
2688
2731
  if (allowed <= 0) return;
2689
2732
  setCustom({ start: shiftDays(custom.start, allowed), end: shiftDays(custom.end, allowed) });
2690
2733
  },
@@ -2706,7 +2749,7 @@ function VisitorInsightsTool(props) {
2706
2749
  style: { position: "relative" },
2707
2750
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2708
2751
  active && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: active.blurb }),
2709
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelBoundary, { resetKey: activePanel, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2752
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelBoundary, { resetKey: `${activePanel}|${range}|${custom.start}|${custom.end}`, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2710
2753
  ReportPanel,
2711
2754
  {
2712
2755
  tabId: active?.id ?? "overview",