@liiift-studio/sanity-visitor-insights 0.20.0 → 0.21.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
@@ -170,6 +170,28 @@ interface ComparisonBarProps {
170
170
  * Studio bundle and the theme-token bridging that a chart library would need for light and dark.
171
171
  */
172
172
  declare function ComparisonBar({ label, metric, max, tone }: ComparisonBarProps): React.ReactElement;
173
+ /** Props for ChartData. */
174
+ interface ChartDataProps<Row> {
175
+ /** What the disclosure is called. Names the chart it belongs to. */
176
+ label: string;
177
+ rows: Row[];
178
+ columns: Array<SortColumn<Row>>;
179
+ rowKey: (row: Row) => string;
180
+ exportName?: string;
181
+ }
182
+ /**
183
+ * The figures behind a chart, as a table, collapsed by default.
184
+ *
185
+ * A chart is a picture of the data and not the data. Every other panel here renders a table for
186
+ * exactly that reason — the file header used to say so, and it stopped being true the moment the
187
+ * charts landed, leaving two time series with no equivalent anywhere in the tool.
188
+ *
189
+ * One disclosure serves four readers at once: someone using a screen reader, for whom an SVG under
190
+ * `role="img"` is a single opaque node; someone navigating by keyboard; someone who cannot resolve
191
+ * axis type at any size; and anyone who wants these numbers in a spreadsheet, since the table
192
+ * brings sorting and CSV copy with it. Collapsed, so it costs a sighted reader one line.
193
+ */
194
+ declare function ChartData<Row>({ label, rows, columns, rowKey, exportName }: ChartDataProps<Row>): React.ReactElement | null;
173
195
  /** One bar of a proportion chart. */
174
196
  interface ProportionBar {
175
197
  key: string;
@@ -394,6 +416,17 @@ interface CrossSourceTimelineProps {
394
416
  markers?: TimelineMarker[];
395
417
  /** ISO 4217 code for any `money` series. */
396
418
  currency?: string | null;
419
+ /**
420
+ * Called when the reader drags across a span, with inclusive ISO dates.
421
+ *
422
+ * Time is the only dimension all four sources genuinely share, which makes it the only thing
423
+ * worth linking on — a referrer row carries no date and a funnel step carries no source, so
424
+ * value-based cross-filtering would need dimensions the reports do not request and could not
425
+ * afford. Dragging a span and having every panel reflow to it is the whole of the cross-filter
426
+ * this data model supports, and it costs one callback: the range state already drives
427
+ * everything.
428
+ */
429
+ onBrush?: (start: string, end: string) => void;
397
430
  }
398
431
  /**
399
432
  * The cross-source timeline.
@@ -401,7 +434,7 @@ interface CrossSourceTimelineProps {
401
434
  * Renders nothing rather than an empty frame when there is not enough to plot — two points cannot
402
435
  * show a shape, and an axis with one dot on it invites a reading it cannot support.
403
436
  */
404
- declare function CrossSourceTimeline({ series, markers, currency }: CrossSourceTimelineProps): React.ReactElement | null;
437
+ declare function CrossSourceTimeline({ series, markers, currency, onBrush }: CrossSourceTimelineProps): React.ReactElement | null;
405
438
 
406
439
  /**
407
440
  * The four report panels.
@@ -432,9 +465,11 @@ declare function CrossSourceTimeline({ series, markers, currency }: CrossSourceT
432
465
  * under plumbing, on tab four, behind a door labelled for the instrument. This is the same data,
433
466
  * first, under a heading that says what it is.
434
467
  */
435
- declare function OverviewPanel({ data, previous }: {
468
+ declare function OverviewPanel({ data, previous, onBrush }: {
436
469
  data: MeasurementHealthData;
437
470
  previous?: MeasurementHealthData;
471
+ /** Narrow every panel to a span dragged on the timeline. */
472
+ onBrush?: (start: string, end: string) => void;
438
473
  }): React.ReactElement;
439
474
  /**
440
475
  * Data health — the instrument, deliberately last.
@@ -512,4 +547,4 @@ interface VisitorInsightsPluginOptions {
512
547
  */
513
548
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
514
549
 
515
- export { AcquisitionData, AcquisitionPanel, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, 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 };
550
+ export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, 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 };
package/dist/index.d.ts CHANGED
@@ -170,6 +170,28 @@ interface ComparisonBarProps {
170
170
  * Studio bundle and the theme-token bridging that a chart library would need for light and dark.
171
171
  */
172
172
  declare function ComparisonBar({ label, metric, max, tone }: ComparisonBarProps): React.ReactElement;
173
+ /** Props for ChartData. */
174
+ interface ChartDataProps<Row> {
175
+ /** What the disclosure is called. Names the chart it belongs to. */
176
+ label: string;
177
+ rows: Row[];
178
+ columns: Array<SortColumn<Row>>;
179
+ rowKey: (row: Row) => string;
180
+ exportName?: string;
181
+ }
182
+ /**
183
+ * The figures behind a chart, as a table, collapsed by default.
184
+ *
185
+ * A chart is a picture of the data and not the data. Every other panel here renders a table for
186
+ * exactly that reason — the file header used to say so, and it stopped being true the moment the
187
+ * charts landed, leaving two time series with no equivalent anywhere in the tool.
188
+ *
189
+ * One disclosure serves four readers at once: someone using a screen reader, for whom an SVG under
190
+ * `role="img"` is a single opaque node; someone navigating by keyboard; someone who cannot resolve
191
+ * axis type at any size; and anyone who wants these numbers in a spreadsheet, since the table
192
+ * brings sorting and CSV copy with it. Collapsed, so it costs a sighted reader one line.
193
+ */
194
+ declare function ChartData<Row>({ label, rows, columns, rowKey, exportName }: ChartDataProps<Row>): React.ReactElement | null;
173
195
  /** One bar of a proportion chart. */
174
196
  interface ProportionBar {
175
197
  key: string;
@@ -394,6 +416,17 @@ interface CrossSourceTimelineProps {
394
416
  markers?: TimelineMarker[];
395
417
  /** ISO 4217 code for any `money` series. */
396
418
  currency?: string | null;
419
+ /**
420
+ * Called when the reader drags across a span, with inclusive ISO dates.
421
+ *
422
+ * Time is the only dimension all four sources genuinely share, which makes it the only thing
423
+ * worth linking on — a referrer row carries no date and a funnel step carries no source, so
424
+ * value-based cross-filtering would need dimensions the reports do not request and could not
425
+ * afford. Dragging a span and having every panel reflow to it is the whole of the cross-filter
426
+ * this data model supports, and it costs one callback: the range state already drives
427
+ * everything.
428
+ */
429
+ onBrush?: (start: string, end: string) => void;
397
430
  }
398
431
  /**
399
432
  * The cross-source timeline.
@@ -401,7 +434,7 @@ interface CrossSourceTimelineProps {
401
434
  * Renders nothing rather than an empty frame when there is not enough to plot — two points cannot
402
435
  * show a shape, and an axis with one dot on it invites a reading it cannot support.
403
436
  */
404
- declare function CrossSourceTimeline({ series, markers, currency }: CrossSourceTimelineProps): React.ReactElement | null;
437
+ declare function CrossSourceTimeline({ series, markers, currency, onBrush }: CrossSourceTimelineProps): React.ReactElement | null;
405
438
 
406
439
  /**
407
440
  * The four report panels.
@@ -432,9 +465,11 @@ declare function CrossSourceTimeline({ series, markers, currency }: CrossSourceT
432
465
  * under plumbing, on tab four, behind a door labelled for the instrument. This is the same data,
433
466
  * first, under a heading that says what it is.
434
467
  */
435
- declare function OverviewPanel({ data, previous }: {
468
+ declare function OverviewPanel({ data, previous, onBrush }: {
436
469
  data: MeasurementHealthData;
437
470
  previous?: MeasurementHealthData;
471
+ /** Narrow every panel to a span dragged on the timeline. */
472
+ onBrush?: (start: string, end: string) => void;
438
473
  }): React.ReactElement;
439
474
  /**
440
475
  * Data health — the instrument, deliberately last.
@@ -512,4 +547,4 @@ interface VisitorInsightsPluginOptions {
512
547
  */
513
548
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
514
549
 
515
- export { AcquisitionData, AcquisitionPanel, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, 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 };
550
+ export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, 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 };
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AcquisitionPanel: () => AcquisitionPanel,
34
+ ChartData: () => ChartData,
34
35
  ComparisonBar: () => ComparisonBar,
35
36
  CrossSourceTimeline: () => CrossSourceTimeline,
36
37
  DataHealthPanel: () => DataHealthPanel,
@@ -76,6 +77,10 @@ var import_sanity_ui_compat4 = require("@liiift-studio/sanity-ui-compat");
76
77
  // src/studio/useReport.ts
77
78
  var import_react = require("react");
78
79
  var import_sanity = require("sanity");
80
+ var envelopeCache = /* @__PURE__ */ new Map();
81
+ function cacheKey(report, range, custom) {
82
+ return [report, range, custom?.start ?? "", custom?.end ?? ""].join("|");
83
+ }
79
84
  var API_VERSION = "2024-03-01";
80
85
  function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
81
86
  const client = (0, import_sanity.useClient)({ apiVersion: API_VERSION });
@@ -88,7 +93,10 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
88
93
  const requestId = requestIdRef.current + 1;
89
94
  requestIdRef.current = requestId;
90
95
  const controller = new AbortController();
91
- setState((current) => current.status === "ready" ? { ...current, stale: true } : { status: "loading" });
96
+ const key = cacheKey(report, range, custom);
97
+ const cached = envelopeCache.get(key);
98
+ if (cached) setState({ status: "ready", envelope: cached, stale: true });
99
+ else setState((current) => current.status === "ready" ? { ...current, stale: true } : { status: "loading" });
92
100
  async function run() {
93
101
  const token = client.config().token;
94
102
  if (!token) {
@@ -125,6 +133,7 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
125
133
  }
126
134
  const envelope = await response.json();
127
135
  if (requestIdRef.current !== requestId) return;
136
+ envelopeCache.set(key, envelope);
128
137
  setState({ status: "ready", envelope });
129
138
  } catch (e) {
130
139
  if (controller.signal.aborted || requestIdRef.current !== requestId) return;
@@ -137,6 +146,46 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
137
146
  return { state, reload };
138
147
  }
139
148
 
149
+ // src/core/ranges.ts
150
+ function formatInTimeZone(date, timeZone) {
151
+ const parts = new Intl.DateTimeFormat("en-CA", {
152
+ timeZone,
153
+ year: "numeric",
154
+ month: "2-digit",
155
+ day: "2-digit"
156
+ }).formatToParts(date);
157
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
158
+ return `${get("year")}-${get("month")}-${get("day")}`;
159
+ }
160
+ function shiftDays(isoDate, days) {
161
+ const time = Date.parse(`${isoDate}T00:00:00Z`);
162
+ if (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`);
163
+ return new Date(time + days * 864e5).toISOString().slice(0, 10);
164
+ }
165
+ function daysBetween(start, end) {
166
+ return Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 864e5) + 1;
167
+ }
168
+ var RANGE_DAYS = {
169
+ week: 7,
170
+ month: 30,
171
+ quarter: 91,
172
+ year: 365
173
+ };
174
+ function resolveRange(key, timezone, now = /* @__PURE__ */ new Date()) {
175
+ const end = formatInTimeZone(now, timezone);
176
+ const start = shiftDays(end, -(RANGE_DAYS[key] - 1));
177
+ return { key, start, end, timezone };
178
+ }
179
+ function previousRange(range) {
180
+ const span = daysBetween(range.start, range.end);
181
+ return {
182
+ key: range.key,
183
+ start: shiftDays(range.start, -span),
184
+ end: shiftDays(range.end, -span),
185
+ timezone: range.timezone
186
+ };
187
+ }
188
+
140
189
  // src/studio/Figure.tsx
141
190
  var import_react2 = __toESM(require("react"));
142
191
  var import_sanity_ui_compat = require("@liiift-studio/sanity-ui-compat");
@@ -298,6 +347,32 @@ function ComparisonBar({ label, metric, max, tone = "default" }) {
298
347
  )
299
348
  ] });
300
349
  }
350
+ function ChartData({ label, rows, columns, rowKey, exportName }) {
351
+ if (rows.length === 0) return null;
352
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("details", { style: disclosureBlock, children: [
353
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("summary", { style: disclosureSummary, children: label }),
354
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { marginTop: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
355
+ SortableTable,
356
+ {
357
+ caption: label,
358
+ rows,
359
+ columns,
360
+ rowKey,
361
+ exportName
362
+ }
363
+ ) })
364
+ ] });
365
+ }
366
+ var disclosureBlock = { width: "100%" };
367
+ var disclosureSummary = {
368
+ cursor: "pointer",
369
+ font: "inherit",
370
+ fontSize: "0.85em",
371
+ opacity: 0.75,
372
+ textDecoration: "underline",
373
+ textUnderlineOffset: 3,
374
+ width: "fit-content"
375
+ };
301
376
  function ProportionChart({ bars, format, totalLabel }) {
302
377
  if (bars.length === 0) return null;
303
378
  const total = bars.reduce((sum, bar) => sum + bar.value, 0);
@@ -967,9 +1042,11 @@ function formatValue(value, unit, currency) {
967
1042
  function tickLabel(date) {
968
1043
  return `${date.getUTCDate()} ${date.toLocaleString("en-GB", { month: "short", timeZone: "UTC" })}`;
969
1044
  }
970
- function CrossSourceTimeline({ series, markers = [], currency }) {
1045
+ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
971
1046
  const [hoverIndex, setHoverIndex] = (0, import_react3.useState)(null);
972
1047
  const [pinned, setPinned] = (0, import_react3.useState)(false);
1048
+ const [brushAnchor, setBrushAnchor] = (0, import_react3.useState)(null);
1049
+ const [brushed, setBrushed] = (0, import_react3.useState)(null);
973
1050
  const frameRef = (0, import_react3.useRef)(null);
974
1051
  const dates = (0, import_react3.useMemo)(() => {
975
1052
  const all = /* @__PURE__ */ new Set();
@@ -982,18 +1059,27 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
982
1059
  if (dates.length === 0) return null;
983
1060
  return (0, import_d3_scale.scaleUtc)().domain([/* @__PURE__ */ new Date(`${dates[0]}T00:00:00Z`), /* @__PURE__ */ new Date(`${dates[dates.length - 1]}T00:00:00Z`)]).range([GUTTER, GUTTER + plotWidth]);
984
1061
  }, [dates, plotWidth]);
985
- const onMove = (0, import_react3.useCallback)(
986
- (event) => {
1062
+ const indexAt = (0, import_react3.useCallback)(
1063
+ (clientX) => {
987
1064
  const frame = frameRef.current;
988
- if (!frame || dates.length === 0) return;
1065
+ if (!frame || dates.length === 0) return null;
989
1066
  const box = frame.getBoundingClientRect();
990
- const ratio = (event.clientX - box.left) / box.width * WIDTH;
1067
+ const ratio = (clientX - box.left) / box.width * WIDTH;
991
1068
  const clamped = Math.max(GUTTER, Math.min(GUTTER + plotWidth, ratio));
992
1069
  const index = Math.round((clamped - GUTTER) / plotWidth * (dates.length - 1));
993
- setHoverIndex(Math.max(0, Math.min(dates.length - 1, index)));
1070
+ return Math.max(0, Math.min(dates.length - 1, index));
994
1071
  },
995
1072
  [dates.length, plotWidth]
996
1073
  );
1074
+ const onMove = (0, import_react3.useCallback)(
1075
+ (event) => {
1076
+ const index = indexAt(event.clientX);
1077
+ if (index === null) return;
1078
+ setHoverIndex(index);
1079
+ if (brushAnchor !== null) setBrushed([Math.min(brushAnchor, index), Math.max(brushAnchor, index)]);
1080
+ },
1081
+ [indexAt, brushAnchor]
1082
+ );
997
1083
  if (dates.length < 3 || series.length === 0 || !x) return null;
998
1084
  const hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null;
999
1085
  const revealed = pinned || hoverIndex !== null;
@@ -1027,9 +1113,32 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1027
1113
  {
1028
1114
  ref: frameRef,
1029
1115
  viewBox: `0 0 ${WIDTH} ${height}`,
1030
- style: { width: "100%", height: "auto", display: "block" },
1031
1116
  onMouseMove: onMove,
1032
- onMouseLeave: () => setHoverIndex(null),
1117
+ onMouseLeave: () => {
1118
+ setHoverIndex(null);
1119
+ setBrushAnchor(null);
1120
+ },
1121
+ onMouseDown: (event) => {
1122
+ if (!onBrush) return;
1123
+ const index = indexAt(event.clientX);
1124
+ if (index === null) return;
1125
+ event.preventDefault();
1126
+ setBrushAnchor(index);
1127
+ setBrushed([index, index]);
1128
+ },
1129
+ onMouseUp: () => {
1130
+ if (!onBrush || brushAnchor === null) return;
1131
+ const index = hoverIndex ?? brushAnchor;
1132
+ const from = Math.min(brushAnchor, index);
1133
+ const to = Math.max(brushAnchor, index);
1134
+ setBrushAnchor(null);
1135
+ if (to - from < 1) {
1136
+ setBrushed(null);
1137
+ return;
1138
+ }
1139
+ onBrush(dates[from], dates[to]);
1140
+ },
1141
+ style: { width: "100%", height: "auto", display: "block", cursor: onBrush ? "col-resize" : "default" },
1033
1142
  tabIndex: 0,
1034
1143
  role: "img",
1035
1144
  "aria-label": summary,
@@ -1044,11 +1153,21 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1044
1153
  if (event.key === "End") next = dates.length - 1;
1045
1154
  if (event.key === "Escape") {
1046
1155
  setHoverIndex(null);
1156
+ setBrushed(null);
1157
+ return;
1158
+ }
1159
+ if (event.key === "Enter" && brushed && brushed[1] > brushed[0] && onBrush) {
1160
+ event.preventDefault();
1161
+ onBrush(dates[brushed[0]], dates[brushed[1]]);
1047
1162
  return;
1048
1163
  }
1049
1164
  if (next === null) return;
1050
1165
  event.preventDefault();
1051
1166
  setHoverIndex(next);
1167
+ if (event.shiftKey && onBrush) {
1168
+ const anchor = brushed ? brushed[0] : current;
1169
+ setBrushed([Math.min(anchor, next), Math.max(anchor, next)]);
1170
+ }
1052
1171
  },
1053
1172
  children: [
1054
1173
  series.map((row, rowIndex) => {
@@ -1110,6 +1229,30 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1110
1229
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("circle", { cx: at, cy: TOP_PAD - 8, r: 3, fill: "currentColor", opacity: 0.7 })
1111
1230
  ] }, `${marker.date}-${marker.label}`);
1112
1231
  }),
1232
+ brushed && brushed[1] > brushed[0] && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { "aria-hidden": "true", children: [
1233
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1234
+ "rect",
1235
+ {
1236
+ x: GUTTER,
1237
+ y: TOP_PAD - 8,
1238
+ width: Math.max(0, x(/* @__PURE__ */ new Date(`${dates[brushed[0]]}T00:00:00Z`)) - GUTTER),
1239
+ height: series.length * ROW_HEIGHT - 10,
1240
+ fill: "currentColor",
1241
+ opacity: 0.12
1242
+ }
1243
+ ),
1244
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1245
+ "rect",
1246
+ {
1247
+ x: x(/* @__PURE__ */ new Date(`${dates[brushed[1]]}T00:00:00Z`)),
1248
+ y: TOP_PAD - 8,
1249
+ width: Math.max(0, GUTTER + plotWidth - x(/* @__PURE__ */ new Date(`${dates[brushed[1]]}T00:00:00Z`))),
1250
+ height: series.length * ROW_HEIGHT - 10,
1251
+ fill: "currentColor",
1252
+ opacity: 0.12
1253
+ }
1254
+ )
1255
+ ] }),
1113
1256
  hoveredDate && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1114
1257
  "line",
1115
1258
  {
@@ -1194,7 +1337,8 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1194
1337
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: sentence }, sentence)
1195
1338
  )),
1196
1339
  series.some((row) => row.shortfall) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Shaded: what your analytics did not see." }),
1197
- markers.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Vertical rules mark campaign sends." })
1340
+ markers.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Vertical rules mark campaign sends." }),
1341
+ onBrush && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Drag across the chart \u2014 or hold shift and use the arrow keys, then Enter \u2014 to narrow every panel to that span." })
1198
1342
  ] })
1199
1343
  ] });
1200
1344
  }
@@ -1268,7 +1412,7 @@ var cardGrid = {
1268
1412
  };
1269
1413
  var cell = { padding: "8px 12px", textAlign: "left", borderBottom: "1px solid var(--card-border-color)" };
1270
1414
  var numericCell = { ...cell, textAlign: "right" };
1271
- function OverviewPanel({ data, previous }) {
1415
+ function OverviewPanel({ data, previous, onBrush }) {
1272
1416
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
1273
1417
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Verdict, { data, previous }),
1274
1418
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: cardGrid, children: [
@@ -1297,9 +1441,51 @@ function OverviewPanel({ data, previous }) {
1297
1441
  (data.crossSource?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1298
1442
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Everything, on one time axis" }),
1299
1443
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Each row has its own scale. Hover a day, or use the control below, to see what each source saw of it." }),
1444
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1445
+ ChartData,
1446
+ {
1447
+ label: "Show these figures as a table",
1448
+ rows: data.crossSource ?? [],
1449
+ rowKey: (d) => d.date,
1450
+ exportName: "timeline",
1451
+ columns: [
1452
+ { 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 }) },
1453
+ {
1454
+ key: "vercel",
1455
+ label: "Pageviews",
1456
+ numeric: true,
1457
+ sortValue: (d) => d.vercelPageviews,
1458
+ render: (d) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: d.vercelPageviews === null ? "\u2014" : formatCount(d.vercelPageviews) })
1459
+ },
1460
+ {
1461
+ key: "ga4",
1462
+ label: "Seen by GA4",
1463
+ numeric: true,
1464
+ sortValue: (d) => d.ga4Pageviews,
1465
+ render: (d) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: d.ga4Pageviews === null ? "\u2014" : formatCount(d.ga4Pageviews) })
1466
+ },
1467
+ {
1468
+ key: "orders",
1469
+ label: "Orders",
1470
+ numeric: true,
1471
+ sortValue: (d) => d.orders,
1472
+ render: (d) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: d.orders === null ? "\u2014" : formatCount(d.orders) })
1473
+ },
1474
+ {
1475
+ key: "revenue",
1476
+ label: "Revenue",
1477
+ numeric: true,
1478
+ sortValue: (d) => d.revenue,
1479
+ exportValue: (d) => d.revenue === null ? null : formatMoney(d.revenue, data.currency ?? null),
1480
+ render: (d) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: d.revenue === null ? "\u2014" : formatMoney(d.revenue, data.currency ?? null) })
1481
+ }
1482
+ ]
1483
+ }
1484
+ ),
1300
1485
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1301
1486
  CrossSourceTimeline,
1302
1487
  {
1488
+ onBrush,
1303
1489
  currency: data.currency ?? null,
1304
1490
  markers: data.timelineEvents ?? [],
1305
1491
  series: [
@@ -1849,6 +2035,25 @@ function tabStyle(selected) {
1849
2035
  whiteSpace: "nowrap"
1850
2036
  };
1851
2037
  }
2038
+ var contextRow = {
2039
+ display: "flex",
2040
+ gap: 12,
2041
+ alignItems: "baseline",
2042
+ flexWrap: "wrap"
2043
+ };
2044
+ var inlineClear = {
2045
+ appearance: "none",
2046
+ background: "transparent",
2047
+ border: "none",
2048
+ color: "inherit",
2049
+ font: "inherit",
2050
+ fontSize: "0.8em",
2051
+ opacity: 0.75,
2052
+ padding: 0,
2053
+ textDecoration: "underline",
2054
+ textUnderlineOffset: 3,
2055
+ cursor: "pointer"
2056
+ };
1852
2057
  function rangeButton(selected) {
1853
2058
  return {
1854
2059
  appearance: "none",
@@ -2065,7 +2270,8 @@ function ReportPanel({
2065
2270
  report,
2066
2271
  apiBaseUrl,
2067
2272
  range,
2068
- custom
2273
+ custom,
2274
+ onBrush
2069
2275
  }) {
2070
2276
  const { state, reload } = useReport({ apiBaseUrl, report, range, custom });
2071
2277
  const diagnostics = useReport({
@@ -2104,7 +2310,7 @@ function ReportPanel({
2104
2310
  ", the equivalent window immediately before this one.",
2105
2311
  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." })
2106
2312
  ] }),
2107
- tabId === "overview" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OverviewPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data }),
2313
+ tabId === "overview" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OverviewPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data, onBrush }),
2108
2314
  tabId === "data-health" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(DataHealthPanel, { data: state.envelope.data, diagnostics: diagnostics.status === "ready" ? diagnostics.envelope.data : void 0 }),
2109
2315
  tabId === "acquisition" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AcquisitionPanel, { data: state.envelope.data, previous: state.envelope.comparison?.data }),
2110
2316
  tabId === "journey" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(JourneyPanel, { data: state.envelope.data }),
@@ -2175,6 +2381,18 @@ function VisitorInsightsTool(props) {
2175
2381
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(RangeSelector, { value: range, custom, onChange: setRange, onCustomChange: setCustom })
2176
2382
  ] }),
2177
2383
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelTabs, { value: activePanel, onChange: setActivePanel }),
2384
+ range === "custom" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: contextRow, children: [
2385
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: [
2386
+ "Narrowed to ",
2387
+ custom.start,
2388
+ " to ",
2389
+ custom.end,
2390
+ " (",
2391
+ daysBetween(custom.start, custom.end),
2392
+ " days)"
2393
+ ] }),
2394
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", style: inlineClear, onClick: () => setRange("week"), children: "Back to the week" })
2395
+ ] }),
2178
2396
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2179
2397
  import_sanity_ui_compat4.Box,
2180
2398
  {
@@ -2185,7 +2403,20 @@ function VisitorInsightsTool(props) {
2185
2403
  style: { position: "relative" },
2186
2404
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2187
2405
  active && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: active.blurb }),
2188
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelBoundary, { onRetry: () => setActivePanel(activePanel), children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ReportPanel, { tabId: active?.id ?? "overview", report: active?.report ?? "measurement-health", apiBaseUrl, range, custom }) })
2406
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelBoundary, { onRetry: () => setActivePanel(activePanel), children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2407
+ ReportPanel,
2408
+ {
2409
+ tabId: active?.id ?? "overview",
2410
+ report: active?.report ?? "measurement-health",
2411
+ apiBaseUrl,
2412
+ range,
2413
+ custom,
2414
+ onBrush: (start, end) => {
2415
+ setCustom({ start, end });
2416
+ setRange("custom");
2417
+ }
2418
+ }
2419
+ ) })
2189
2420
  ] })
2190
2421
  }
2191
2422
  )
@@ -2358,46 +2589,6 @@ function isValidTimeZone(timeZone) {
2358
2589
  }
2359
2590
  }
2360
2591
 
2361
- // src/core/ranges.ts
2362
- function formatInTimeZone(date, timeZone) {
2363
- const parts = new Intl.DateTimeFormat("en-CA", {
2364
- timeZone,
2365
- year: "numeric",
2366
- month: "2-digit",
2367
- day: "2-digit"
2368
- }).formatToParts(date);
2369
- const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
2370
- return `${get("year")}-${get("month")}-${get("day")}`;
2371
- }
2372
- function shiftDays(isoDate, days) {
2373
- const time = Date.parse(`${isoDate}T00:00:00Z`);
2374
- if (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`);
2375
- return new Date(time + days * 864e5).toISOString().slice(0, 10);
2376
- }
2377
- function daysBetween(start, end) {
2378
- return Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 864e5) + 1;
2379
- }
2380
- var RANGE_DAYS = {
2381
- week: 7,
2382
- month: 30,
2383
- quarter: 91,
2384
- year: 365
2385
- };
2386
- function resolveRange(key, timezone, now = /* @__PURE__ */ new Date()) {
2387
- const end = formatInTimeZone(now, timezone);
2388
- const start = shiftDays(end, -(RANGE_DAYS[key] - 1));
2389
- return { key, start, end, timezone };
2390
- }
2391
- function previousRange(range) {
2392
- const span = daysBetween(range.start, range.end);
2393
- return {
2394
- key: range.key,
2395
- start: shiftDays(range.start, -span),
2396
- end: shiftDays(range.end, -span),
2397
- timezone: range.timezone
2398
- };
2399
- }
2400
-
2401
2592
  // src/index.ts
2402
2593
  var ChartIcon = (0, import_icons.resolveIcon)("ChartUpwardIcon", "chart-upward");
2403
2594
  var visitorInsights = (0, import_sanity2.definePlugin)((options) => {
@@ -2427,6 +2618,7 @@ var index_default = visitorInsights;
2427
2618
  // Annotate the CommonJS export names for ESM import in node:
2428
2619
  0 && (module.exports = {
2429
2620
  AcquisitionPanel,
2621
+ ChartData,
2430
2622
  ComparisonBar,
2431
2623
  CrossSourceTimeline,
2432
2624
  DataHealthPanel,