@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 +38 -3
- package/dist/index.d.ts +38 -3
- package/dist/index.js +246 -54
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +245 -54
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/studio/CrossSourceTimeline.tsx +98 -11
- package/src/studio/Figure.tsx +55 -0
- package/src/studio/VisitorInsightsTool.tsx +49 -2
- package/src/studio/panels.test.tsx +48 -0
- package/src/studio/panels.tsx +40 -2
- package/src/studio/useReport.ts +28 -1
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
|
-
|
|
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
|
|
986
|
-
(
|
|
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 = (
|
|
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
|
-
|
|
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: () =>
|
|
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)(
|
|
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,
|