@liiift-studio/sanity-visitor-insights 0.10.0 → 0.11.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
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-B1YwjGGt.mjs';
4
- export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-B1YwjGGt.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-YGStz1nR.mjs';
4
+ export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-YGStz1nR.mjs';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
@@ -57,6 +57,11 @@ type ReportState<T> = {
57
57
  };
58
58
  /** Options for useReport. */
59
59
  interface UseReportOptions {
60
+ /** Explicit dates, required when `range` is 'custom' and ignored otherwise. */
61
+ custom?: {
62
+ start: string;
63
+ end: string;
64
+ };
60
65
  /** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */
61
66
  apiBaseUrl: string;
62
67
  report: ReportName;
@@ -67,7 +72,7 @@ interface UseReportOptions {
67
72
  *
68
73
  * @returns the current state plus a `reload` for manual refresh
69
74
  */
70
- declare function useReport<T>({ apiBaseUrl, report, range }: UseReportOptions): {
75
+ declare function useReport<T>({ apiBaseUrl, report, range, custom }: UseReportOptions): {
71
76
  state: ReportState<T>;
72
77
  reload: () => void;
73
78
  };
@@ -165,7 +170,7 @@ declare function MeasurementHealthPanel({ data }: {
165
170
  declare function AcquisitionPanel({ data }: {
166
171
  data: AcquisitionData;
167
172
  }): React.ReactElement;
168
- /** Journey — per-step totals with adjacent drop-off, explicitly not a tracked path. */
173
+ /** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
169
174
  declare function JourneyPanel({ data }: {
170
175
  data: JourneyData;
171
176
  }): React.ReactElement;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-B1YwjGGt.js';
4
- export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-B1YwjGGt.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-YGStz1nR.js';
4
+ export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-YGStz1nR.js';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
@@ -57,6 +57,11 @@ type ReportState<T> = {
57
57
  };
58
58
  /** Options for useReport. */
59
59
  interface UseReportOptions {
60
+ /** Explicit dates, required when `range` is 'custom' and ignored otherwise. */
61
+ custom?: {
62
+ start: string;
63
+ end: string;
64
+ };
60
65
  /** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */
61
66
  apiBaseUrl: string;
62
67
  report: ReportName;
@@ -67,7 +72,7 @@ interface UseReportOptions {
67
72
  *
68
73
  * @returns the current state plus a `reload` for manual refresh
69
74
  */
70
- declare function useReport<T>({ apiBaseUrl, report, range }: UseReportOptions): {
75
+ declare function useReport<T>({ apiBaseUrl, report, range, custom }: UseReportOptions): {
71
76
  state: ReportState<T>;
72
77
  reload: () => void;
73
78
  };
@@ -165,7 +170,7 @@ declare function MeasurementHealthPanel({ data }: {
165
170
  declare function AcquisitionPanel({ data }: {
166
171
  data: AcquisitionData;
167
172
  }): React.ReactElement;
168
- /** Journey — per-step totals with adjacent drop-off, explicitly not a tracked path. */
173
+ /** Journey — a funnel, drawn as a tracked sequence or as independent totals, whichever the report used. */
169
174
  declare function JourneyPanel({ data }: {
170
175
  data: JourneyData;
171
176
  }): React.ReactElement;
package/dist/index.js CHANGED
@@ -68,7 +68,7 @@ var import_sanity_ui_compat3 = require("@liiift-studio/sanity-ui-compat");
68
68
  var import_react = require("react");
69
69
  var import_sanity = require("sanity");
70
70
  var API_VERSION = "2024-03-01";
71
- function useReport({ apiBaseUrl, report, range }) {
71
+ function useReport({ apiBaseUrl, report, range, custom }) {
72
72
  const client = (0, import_sanity.useClient)({ apiVersion: API_VERSION });
73
73
  const [state, setState] = (0, import_react.useState)({ status: "idle" });
74
74
  const [nonce, setNonce] = (0, import_react.useState)(0);
@@ -90,14 +90,24 @@ function useReport({ apiBaseUrl, report, range }) {
90
90
  }
91
91
  try {
92
92
  const base = typeof apiBaseUrl === "string" ? apiBaseUrl.replace(/\/$/, "") : "";
93
- const url = `${base}/api/visitor-insights/${report}?range=${range}`;
93
+ const query = new URLSearchParams({ range });
94
+ if (range === "custom" && custom) {
95
+ query.set("start", custom.start);
96
+ query.set("end", custom.end);
97
+ }
98
+ const url = `${base}/api/visitor-insights/${report}?${query.toString()}`;
94
99
  const response = await fetch(url, {
95
100
  headers: { Authorization: `Bearer ${token}` },
96
101
  signal: controller.signal
97
102
  });
98
103
  if (requestIdRef.current !== requestId) return;
99
104
  if (!response.ok) {
100
- const detail = response.status === 401 ? "Not authorised \u2014 sign in to the Studio again." : `Request failed (${response.status})`;
105
+ let detail = response.status === 401 ? "Not authorised \u2014 sign in to the Studio again." : `Request failed (${response.status})`;
106
+ if (response.status === 400) {
107
+ const body = await response.json().catch(() => null);
108
+ if (body?.error) detail = body.error;
109
+ }
110
+ if (requestIdRef.current !== requestId) return;
101
111
  setState({ status: "error", message: detail });
102
112
  return;
103
113
  }
@@ -111,7 +121,7 @@ function useReport({ apiBaseUrl, report, range }) {
111
121
  }
112
122
  void run();
113
123
  return () => controller.abort();
114
- }, [client, apiBaseUrl, report, range, nonce]);
124
+ }, [client, apiBaseUrl, report, range, custom?.start, custom?.end, nonce]);
115
125
  return { state, reload };
116
126
  }
117
127
 
@@ -175,6 +185,83 @@ function ComparisonBar({ label, metric, max, tone = "default" }) {
175
185
  )
176
186
  ] });
177
187
  }
188
+ function FunnelChart({ stages, measurement }) {
189
+ const [activeKey, setActiveKey] = (0, import_react2.useState)(null);
190
+ const refs = (0, import_react2.useRef)([]);
191
+ const entry = stages[0]?.value ?? 0;
192
+ if (stages.length === 0) return null;
193
+ const onKeyDown = (event, index) => {
194
+ let next = null;
195
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") next = Math.min(index + 1, stages.length - 1);
196
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") next = Math.max(index - 1, 0);
197
+ if (event.key === "Home") next = 0;
198
+ if (event.key === "End") next = stages.length - 1;
199
+ if (next === null) return;
200
+ event.preventDefault();
201
+ refs.current[next]?.focus();
202
+ };
203
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ol", { style: funnelList, children: stages.map((stage, index) => {
204
+ const previous = index > 0 ? stages[index - 1] : null;
205
+ const share = entry > 0 ? stage.value / entry : 0;
206
+ const width = Math.max(1.5, Math.min(1, share) * 100);
207
+ const active = activeKey === stage.key;
208
+ const delta = previous ? previous.value - stage.value : 0;
209
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: funnelItem, children: [
210
+ previous && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: funnelGap, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: gapLabel(delta, measurement) }) }),
211
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
212
+ "div",
213
+ {
214
+ ref: (el) => {
215
+ refs.current[index] = el;
216
+ },
217
+ tabIndex: 0,
218
+ role: "listitem",
219
+ style: funnelStage(active),
220
+ onMouseEnter: () => setActiveKey(stage.key),
221
+ onMouseLeave: () => setActiveKey(null),
222
+ onFocus: () => setActiveKey(stage.key),
223
+ onBlur: () => setActiveKey(null),
224
+ onKeyDown: (e) => onKeyDown(e, index),
225
+ children: [
226
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: barHeader, children: [
227
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "medium", children: stage.label }),
228
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "semibold", children: formatCount(stage.value) })
229
+ ] }),
230
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { "aria-hidden": "true", radius: 2, tone: "transparent", border: true, style: funnelTrack, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { tone: "primary", radius: 2, style: { width: `${width}%`, height: "100%" } }) }),
231
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
232
+ index === 0 ? "entry step" : `${formatPercent(share, 1)} of ${stages[0]?.label.toLowerCase()}`,
233
+ index > 1 && stage.conversionFromPrevious !== null && previous ? ` \xB7 ${formatPercent(stage.conversionFromPrevious, 1)} of ${previous.label.toLowerCase()}` : ""
234
+ ] })
235
+ ]
236
+ }
237
+ )
238
+ ] }, stage.key);
239
+ }) });
240
+ }
241
+ function gapLabel(delta, measurement) {
242
+ if (delta === 0) return measurement === "sequence" ? "no drop-off" : "no difference";
243
+ if (delta > 0) return measurement === "sequence" ? `\u2212${formatCount(delta)} did not continue` : `${formatCount(delta)} fewer`;
244
+ return `${formatCount(-delta)} more \u2014 not a subset of the step above`;
245
+ }
246
+ var funnelList = { listStyle: "none", margin: 0, padding: 0 };
247
+ var funnelItem = { display: "grid", gap: 4 };
248
+ var funnelGap = {
249
+ display: "flex",
250
+ justifyContent: "flex-end",
251
+ padding: "4px 2px"
252
+ };
253
+ function funnelStage(active) {
254
+ return {
255
+ display: "grid",
256
+ gap: 6,
257
+ padding: "8px 10px",
258
+ borderRadius: 3,
259
+ border: `1px solid ${active ? "currentColor" : "transparent"}`,
260
+ background: active ? "var(--card-bg-color, rgba(128,128,128,0.08))" : "transparent",
261
+ cursor: "default"
262
+ };
263
+ }
264
+ var funnelTrack = { height: 10, overflow: "hidden" };
178
265
  var chartFrame = { position: "relative", width: "100%" };
179
266
  var readout = {
180
267
  position: "absolute",
@@ -663,17 +750,16 @@ function JourneyPanel({ data }) {
663
750
  const allSteps = data.steps ?? [];
664
751
  const shown = allSteps.filter((step) => step.count.status !== "unavailable");
665
752
  const hiddenSteps = allSteps.filter((step) => step.count.status === "unavailable");
666
- const max = maxOf(shown.map((step) => step.count));
753
+ const stages = shown.flatMap(
754
+ (step) => step.count.status === "unavailable" ? [] : [{ key: step.key, label: step.label, value: step.count.value, conversionFromPrevious: step.conversionFromPrevious }]
755
+ );
756
+ const tracked = data.measurement === "sequence";
667
757
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 4, children: [
668
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, children: data.approximationNote }) }),
669
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Stack, { space: 3, children: shown.map((step, index) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 2, children: [
670
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ComparisonBar, { label: step.label, metric: step.count, max, tone: "primary" }),
671
- step.conversionFromPrevious !== null && index > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
672
- formatPercent(step.conversionFromPrevious, 1),
673
- " of ",
674
- shown[index - 1]?.label.toLowerCase()
675
- ] })
676
- ] }, step.key)) }),
758
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Card, { padding: 3, radius: 2, tone: tracked ? "transparent" : "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 2, children: [
759
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, weight: "medium", children: tracked ? "Tracked funnel" : "Independent per-step totals" }),
760
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 1, muted: tracked, children: data.approximationNote })
761
+ ] }) }),
762
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(FunnelChart, { stages, measurement: data.measurement ?? "independent-totals" }),
677
763
  hiddenSteps.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 1, muted: true, children: [
678
764
  "Not shown: ",
679
765
  hiddenSteps.map((step) => step.label.toLowerCase()).join(", "),
@@ -794,6 +880,12 @@ function DiagnosticsPanel({ data }) {
794
880
 
795
881
  // src/studio/VisitorInsightsTool.tsx
796
882
  var import_jsx_runtime3 = require("react/jsx-runtime");
883
+ var controlRow = {
884
+ display: "flex",
885
+ gap: 6,
886
+ alignItems: "center",
887
+ flexWrap: "wrap"
888
+ };
797
889
  var tabStrip = {
798
890
  display: "flex",
799
891
  gap: 2,
@@ -817,10 +909,39 @@ function tabStyle(selected) {
817
909
  whiteSpace: "nowrap"
818
910
  };
819
911
  }
912
+ function rangeButton(selected) {
913
+ return {
914
+ appearance: "none",
915
+ background: "transparent",
916
+ border: `1px solid ${selected ? "currentColor" : "var(--card-border-color, rgba(128,128,128,0.3))"}`,
917
+ borderRadius: 3,
918
+ color: "inherit",
919
+ opacity: selected ? 1 : 0.7,
920
+ font: "inherit",
921
+ fontSize: "0.85em",
922
+ fontWeight: selected ? 600 : 400,
923
+ padding: "5px 10px",
924
+ cursor: "pointer",
925
+ whiteSpace: "nowrap"
926
+ };
927
+ }
928
+ var pickerRow = { display: "flex", gap: 10, alignItems: "flex-end", flexWrap: "wrap" };
929
+ var pickerField = { display: "grid", gap: 2 };
930
+ var dateInput = {
931
+ font: "inherit",
932
+ fontSize: "0.85em",
933
+ padding: "4px 6px",
934
+ borderRadius: 3,
935
+ border: "1px solid var(--card-border-color, rgba(128,128,128,0.3))",
936
+ background: "transparent",
937
+ color: "inherit",
938
+ colorScheme: "light dark"
939
+ };
820
940
  var RANGES = [
821
- { key: "week", label: "7 days", span: "the last 7 days" },
822
- { key: "quarter", label: "91 days", span: "the last 91 days" },
823
- { key: "year", label: "365 days", span: "the last 365 days" }
941
+ { key: "week", label: "Week", span: "the last 7 days" },
942
+ { key: "month", label: "Month", span: "the last 30 days" },
943
+ { key: "quarter", label: "Quarter", span: "the last 91 days" },
944
+ { key: "year", label: "Year", span: "the last 365 days" }
824
945
  ];
825
946
  var PANELS = [
826
947
  { report: "acquisition", label: "Acquisition", blurb: "Where visitors come from" },
@@ -829,8 +950,14 @@ var PANELS = [
829
950
  { report: "measurement-health", label: "Measurement health", blurb: "How much of reality each source actually sees" },
830
951
  { report: "diagnostics", label: "Diagnostics", blurb: "What to fix before trusting the numbers above" }
831
952
  ];
832
- function RangeSelector({ value, onChange }) {
953
+ function RangeSelector({
954
+ value,
955
+ custom,
956
+ onChange,
957
+ onCustomChange
958
+ }) {
833
959
  const refs = (0, import_react3.useRef)([]);
960
+ const [pickerOpen, setPickerOpen] = (0, import_react3.useState)(value === "custom");
834
961
  const onKeyDown = (0, import_react3.useCallback)(
835
962
  (event) => {
836
963
  const currentIndex = RANGES.findIndex((r) => r.key === value);
@@ -843,33 +970,92 @@ function RangeSelector({ value, onChange }) {
843
970
  event.preventDefault();
844
971
  const next = RANGES[nextIndex];
845
972
  if (!next) return;
973
+ setPickerOpen(false);
846
974
  onChange(next.key);
847
975
  refs.current[nextIndex]?.focus();
848
976
  },
849
977
  [value, onChange]
850
978
  );
851
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Flex, { role: "radiogroup", "aria-label": "Date range", gap: 1, onKeyDown, children: RANGES.map((range, index) => {
852
- const selected = range.key === value;
853
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
854
- import_sanity_ui_compat3.Button,
855
- {
856
- ref: (el) => {
857
- refs.current[index] = el;
858
- },
859
- role: "radio",
860
- "aria-checked": selected,
861
- tabIndex: selected ? 0 : -1,
862
- mode: selected ? "default" : "bleed",
863
- tone: selected ? "primary" : "default",
864
- text: range.label,
865
- title: `Ending today, covering ${range.span}`,
866
- onClick: () => onChange(range.key),
867
- fontSize: 1,
868
- padding: 3
869
- },
870
- range.key
871
- );
872
- }) });
979
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
980
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { role: "radiogroup", "aria-label": "Date range", style: controlRow, onKeyDown, children: [
981
+ RANGES.map((range, index) => {
982
+ const selected = range.key === value;
983
+ return (
984
+ // Plain buttons rather than the UI kit's: the compat shim's DOM fallback does not
985
+ // forward `text`, which renders the whole selector as blank boxes on any Studio
986
+ // version where it cannot resolve Button.
987
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
988
+ "button",
989
+ {
990
+ type: "button",
991
+ ref: (el) => {
992
+ refs.current[index] = el;
993
+ },
994
+ role: "radio",
995
+ "aria-checked": selected,
996
+ tabIndex: selected ? 0 : -1,
997
+ title: `Ending today, covering ${range.span}`,
998
+ style: rangeButton(selected),
999
+ onClick: () => {
1000
+ setPickerOpen(false);
1001
+ onChange(range.key);
1002
+ },
1003
+ children: range.label
1004
+ },
1005
+ range.key
1006
+ )
1007
+ );
1008
+ }),
1009
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1010
+ "button",
1011
+ {
1012
+ type: "button",
1013
+ style: rangeButton(value === "custom"),
1014
+ "aria-expanded": pickerOpen,
1015
+ onClick: () => setPickerOpen((open) => !open),
1016
+ children: value === "custom" ? `${custom.start} to ${custom.end}` : "Custom\u2026"
1017
+ }
1018
+ )
1019
+ ] }),
1020
+ pickerOpen && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: pickerRow, children: [
1021
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { style: pickerField, children: [
1022
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "From" }),
1023
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1024
+ "input",
1025
+ {
1026
+ type: "date",
1027
+ value: custom.start,
1028
+ max: custom.end || void 0,
1029
+ style: dateInput,
1030
+ onChange: (e) => onCustomChange({ ...custom, start: e.currentTarget.value })
1031
+ }
1032
+ )
1033
+ ] }),
1034
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("label", { style: pickerField, children: [
1035
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "To" }),
1036
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1037
+ "input",
1038
+ {
1039
+ type: "date",
1040
+ value: custom.end,
1041
+ min: custom.start || void 0,
1042
+ style: dateInput,
1043
+ onChange: (e) => onCustomChange({ ...custom, end: e.currentTarget.value })
1044
+ }
1045
+ )
1046
+ ] }),
1047
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1048
+ "button",
1049
+ {
1050
+ type: "button",
1051
+ style: rangeButton(true),
1052
+ disabled: !custom.start || !custom.end,
1053
+ onClick: () => onChange("custom"),
1054
+ children: "Apply"
1055
+ }
1056
+ )
1057
+ ] })
1058
+ ] });
873
1059
  }
874
1060
  var SOURCE_LABEL = {
875
1061
  ga4: "Google Analytics",
@@ -933,9 +1119,14 @@ function PanelTabs({ value, onChange }) {
933
1119
  );
934
1120
  }) });
935
1121
  }
936
- function ReportPanel({ report, apiBaseUrl, range }) {
937
- const { state, reload } = useReport({ apiBaseUrl, report, range });
938
- const liveMessage = state.status === "loading" ? "Loading report" : state.status === "ready" ? `${report} updated for the selected ${range}` : state.status === "error" ? `Report failed: ${state.message}` : "";
1122
+ function ReportPanel({
1123
+ report,
1124
+ apiBaseUrl,
1125
+ range,
1126
+ custom
1127
+ }) {
1128
+ const { state, reload } = useReport({ apiBaseUrl, report, range, custom });
1129
+ const liveMessage = state.status === "loading" ? "Loading report" : state.status === "ready" ? `${report} updated for ${range === "custom" ? `${custom.start} to ${custom.end}` : `the selected ${range}`}` : state.status === "error" ? `Report failed: ${state.message}` : "";
939
1130
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
940
1131
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Box, { "aria-live": "polite", style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }, children: liveMessage }),
941
1132
  state.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Flex, { align: "center", gap: 3, padding: 4, children: [
@@ -965,36 +1156,44 @@ function ReportPanel({ report, apiBaseUrl, range }) {
965
1156
  ] })
966
1157
  ] });
967
1158
  }
1159
+ function isoDaysAgo(days) {
1160
+ const date = /* @__PURE__ */ new Date();
1161
+ date.setDate(date.getDate() - days);
1162
+ return date.toISOString().slice(0, 10);
1163
+ }
968
1164
  function VisitorInsightsTool(props) {
969
1165
  const apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? "";
970
1166
  const siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? "";
971
1167
  const [range, setRange] = (0, import_react3.useState)("week");
1168
+ const [custom, setCustom] = (0, import_react3.useState)(() => ({ start: isoDaysAgo(30), end: isoDaysAgo(0) }));
972
1169
  const [activePanel, setActivePanel] = (0, import_react3.useState)("acquisition");
973
1170
  const active = PANELS.find((p) => p.report === activePanel) ?? PANELS[0];
974
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Container, { width: 4, padding: 4, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 5, children: [
975
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [
976
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
1171
+ return (
1172
+ // Named for assistive technology even though the name is not printed: a Studio user with
1173
+ // several tabs open hears which site's figures these are, without the heading repeating
1174
+ // what the Studio navigation already says.
1175
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Container, { width: 4, padding: 4, as: "section", "aria-label": siteLabel ? `Visitor insights for ${siteLabel}` : "Visitor insights", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 5, children: [
1176
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [
977
1177
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 2, children: "Visitor insights" }),
978
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: siteLabel })
1178
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RangeSelector, { value: range, custom, onChange: setRange, onCustomChange: setCustom })
979
1179
  ] }),
980
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RangeSelector, { value: range, onChange: setRange })
981
- ] }),
982
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PanelTabs, { value: activePanel, onChange: setActivePanel }),
983
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
984
- import_sanity_ui_compat3.Box,
985
- {
986
- role: "tabpanel",
987
- id: `panel-${activePanel}`,
988
- "aria-labelledby": `tab-${activePanel}`,
989
- tabIndex: 0,
990
- style: { position: "relative" },
991
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
992
- active && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: active.blurb }),
993
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ReportPanel, { report: activePanel, apiBaseUrl, range })
994
- ] })
995
- }
996
- )
997
- ] }) });
1180
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(PanelTabs, { value: activePanel, onChange: setActivePanel }),
1181
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1182
+ import_sanity_ui_compat3.Box,
1183
+ {
1184
+ role: "tabpanel",
1185
+ id: `panel-${activePanel}`,
1186
+ "aria-labelledby": `tab-${activePanel}`,
1187
+ tabIndex: 0,
1188
+ style: { position: "relative" },
1189
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
1190
+ active && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: active.blurb }),
1191
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ReportPanel, { report: activePanel, apiBaseUrl, range, custom })
1192
+ ] })
1193
+ }
1194
+ )
1195
+ ] }) })
1196
+ );
998
1197
  }
999
1198
 
1000
1199
  // src/types.ts
@@ -1128,6 +1327,7 @@ function daysBetween(start, end) {
1128
1327
  }
1129
1328
  var RANGE_DAYS = {
1130
1329
  week: 7,
1330
+ month: 30,
1131
1331
  quarter: 91,
1132
1332
  year: 365
1133
1333
  };