@liiift-studio/sanity-visitor-insights 0.45.0 → 0.47.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/README.md CHANGED
@@ -19,7 +19,23 @@ only as a conversion anchor and a count; no revenue, and no customer data.
19
19
  | **Typeface interest** | Which families get viewed, tested and bought? |
20
20
  | **Diagnostics** | What to fix before trusting any of the above? |
21
21
 
22
- Each panel offers a week, quarter or year range.
22
+ Each panel offers a week, quarter or year range, a custom window, and a choice of what the changes
23
+ are measured against — the preceding window, or the same window a year earlier. Type sales are
24
+ seasonal, so a quiet August against a quiet July often says less than August against last August.
25
+ Both cost the same: one comparison window either way.
26
+
27
+ Four figures are derived from the ones above rather than measured separately:
28
+
29
+ | Figure | How it is arrived at |
30
+ |---|---|
31
+ | **Average order value** | Revenue over the orders that *carry an amount*, not over every counted order. Marked partial, naming both counts, whenever those differ. |
32
+ | **Visitors per order** | Vercel visitors over orders. Vercel because it is server-side and complete; GA4 sessions would flatter the ratio by roughly the shortfall. |
33
+ | **Orders after a send** | What the order book records in the three days following each email, ending early if another send lands first, so no order is counted twice. |
34
+ | **Revenue per 1,000 sent** | The same window's revenue scaled to a thousand addresses — per send it is fractions of a cent and every campaign renders as the same zero. |
35
+
36
+ The last two are **what happened next, not what the send caused**. They are built from send times and
37
+ order times, both of which are exact, rather than from GA4's campaign attribution, which at a
38
+ foundry's volumes is withheld for being too thin to divide revenue by.
23
39
 
24
40
  ---
25
41
 
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, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CWGvBi3C.mjs';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CWGvBi3C.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CeB8esIA.mjs';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CeB8esIA.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -51,6 +51,8 @@ interface UseReportOptions {
51
51
  apiBaseUrl: string;
52
52
  report: ReportName;
53
53
  range: RangeKey;
54
+ /** Which baseline the deltas are measured against. Defaults to the preceding window. */
55
+ compare?: 'previous-period' | 'same-period-last-year';
54
56
  /**
55
57
  * Whether to fetch at all. Defaults to true.
56
58
  *
@@ -64,7 +66,7 @@ interface UseReportOptions {
64
66
  *
65
67
  * @returns the current state plus a `reload` for manual refresh
66
68
  */
67
- declare function useReport<T>({ apiBaseUrl, report, range, custom, enabled }: UseReportOptions): {
69
+ declare function useReport<T>({ apiBaseUrl, report, range, custom, compare, enabled }: UseReportOptions): {
68
70
  state: ReportState<T>;
69
71
  reload: () => void;
70
72
  };
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, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CWGvBi3C.js';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CWGvBi3C.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CeB8esIA.js';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CeB8esIA.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -51,6 +51,8 @@ interface UseReportOptions {
51
51
  apiBaseUrl: string;
52
52
  report: ReportName;
53
53
  range: RangeKey;
54
+ /** Which baseline the deltas are measured against. Defaults to the preceding window. */
55
+ compare?: 'previous-period' | 'same-period-last-year';
54
56
  /**
55
57
  * Whether to fetch at all. Defaults to true.
56
58
  *
@@ -64,7 +66,7 @@ interface UseReportOptions {
64
66
  *
65
67
  * @returns the current state plus a `reload` for manual refresh
66
68
  */
67
- declare function useReport<T>({ apiBaseUrl, report, range, custom, enabled }: UseReportOptions): {
69
+ declare function useReport<T>({ apiBaseUrl, report, range, custom, compare, enabled }: UseReportOptions): {
68
70
  state: ReportState<T>;
69
71
  reload: () => void;
70
72
  };
package/dist/index.js CHANGED
@@ -95,14 +95,14 @@ function rememberShortfall(base, range, custom, data) {
95
95
  shortfallByWindow.set(`${base}|${windowKey(range, custom)}`, ratio);
96
96
  }
97
97
  }
98
- function cacheKey(base, report, range, custom) {
99
- return [base, report, range, custom?.start ?? "", custom?.end ?? ""].join("|");
98
+ function cacheKey(base, report, range, custom, compare) {
99
+ return [base, report, range, custom?.start ?? "", custom?.end ?? "", compare ?? "previous-period"].join("|");
100
100
  }
101
101
  function holdsPreviousAnswer(current, nextReport) {
102
102
  return current.status === "ready" && current.envelope.report === nextReport;
103
103
  }
104
104
  var API_VERSION = "2024-03-01";
105
- function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
105
+ function useReport({ apiBaseUrl, report, range, custom, compare, enabled = true }) {
106
106
  const client = (0, import_sanity.useClient)({ apiVersion: API_VERSION });
107
107
  const [state, setState] = (0, import_react.useState)({ status: "idle" });
108
108
  const [nonce, setNonce] = (0, import_react.useState)(0);
@@ -113,7 +113,7 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
113
113
  const requestId = requestIdRef.current + 1;
114
114
  requestIdRef.current = requestId;
115
115
  const controller = new AbortController();
116
- const key = cacheKey(apiBaseUrl, report, range, custom);
116
+ const key = cacheKey(apiBaseUrl, report, range, custom, compare);
117
117
  const cached = envelopeCache.get(key);
118
118
  if (cached) setState({ status: "ready", envelope: cached, stale: true });
119
119
  else {
@@ -131,6 +131,7 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
131
131
  try {
132
132
  const base = typeof apiBaseUrl === "string" ? apiBaseUrl.replace(/\/$/, "") : "";
133
133
  const query = new URLSearchParams({ range });
134
+ if (compare && compare !== "previous-period") query.set("compare", compare);
134
135
  if (range === "custom" && custom) {
135
136
  query.set("start", custom.start);
136
137
  query.set("end", custom.end);
@@ -243,7 +244,15 @@ function resolveRange(key, timezone, now = /* @__PURE__ */ new Date()) {
243
244
  const start = shiftDays(end, -(RANGE_DAYS[key] - 1));
244
245
  return { key, start, end, timezone };
245
246
  }
246
- function previousRange(range) {
247
+ function previousRange(range, basis = "previous-period") {
248
+ if (basis === "same-period-last-year") {
249
+ return {
250
+ key: range.key,
251
+ start: shiftDays(range.start, -364),
252
+ end: shiftDays(range.end, -364),
253
+ timezone: range.timezone
254
+ };
255
+ }
247
256
  const span = daysBetween(range.start, range.end);
248
257
  return {
249
258
  key: range.key,
@@ -252,6 +261,7 @@ function previousRange(range) {
252
261
  timezone: range.timezone
253
262
  };
254
263
  }
264
+ var SEND_WINDOW_DAYS = 3;
255
265
 
256
266
  // src/studio/Figure.tsx
257
267
  var import_react2 = __toESM(require("react"));
@@ -1494,6 +1504,33 @@ var legendRow = {
1494
1504
  alignItems: "center"
1495
1505
  };
1496
1506
 
1507
+ // src/types.ts
1508
+ function estimated(value, low, high, basis) {
1509
+ return { status: "estimated", value, low, high, basis };
1510
+ }
1511
+ function ok(value) {
1512
+ return { status: "ok", value };
1513
+ }
1514
+ function unavailable(reason, detail) {
1515
+ return { status: "unavailable", reason, detail };
1516
+ }
1517
+ function partial(value, coveredFrom, note) {
1518
+ return { status: "partial", value, coveredFrom, note };
1519
+ }
1520
+ function valueOrNull(metric) {
1521
+ return metric.status === "unavailable" ? null : metric.value;
1522
+ }
1523
+ var REPORT_NAMES = [
1524
+ "measurement-health",
1525
+ "acquisition",
1526
+ "journey",
1527
+ "typeface-interest",
1528
+ "diagnostics"
1529
+ ];
1530
+ function isReportName(value) {
1531
+ return typeof value === "string" && REPORT_NAMES.includes(value);
1532
+ }
1533
+
1497
1534
  // src/studio/panels.tsx
1498
1535
  var import_jsx_runtime3 = require("react/jsx-runtime");
1499
1536
  function metricSortValue(metric) {
@@ -1589,6 +1626,26 @@ function OverviewPanel({ data, previous, onBrush }) {
1589
1626
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Everyone subscribed today, not just this period. Mailchimp\u2019s own count, so it is not consent-gated or blockable." })
1590
1627
  ] }) })
1591
1628
  ] }),
1629
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: cardGrid, children: [
1630
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1631
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Average order" }),
1632
+ (() => {
1633
+ const average = averageOrderValue(data);
1634
+ const value = metricSortValue(average);
1635
+ if (value === null) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: average, label: "Average order value" });
1636
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: figureRow, children: [
1637
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatMoney(value, data.currency ?? null) }),
1638
+ average.status === "partial" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "partial" })
1639
+ ] });
1640
+ })(),
1641
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "From your own orders, so it is exact \u2014 but only across the orders that carry an amount, which is what the revenue figure covers." })
1642
+ ] }) }),
1643
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "transparent", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1644
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Visitors per order" }),
1645
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: visitorsPerOrder(data), label: "Visitors per order" }),
1646
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Against Vercel\u2019s visitor count, not Google Analytics\u2019 \u2014 dividing by GA4 would flatter this by whatever share it is missing. Read it as a ceiling: some of those visitors are crawlers or repeat devices." })
1647
+ ] }) })
1648
+ ] }),
1592
1649
  (data.crossSource?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1593
1650
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Everything, on one time axis" }),
1594
1651
  /* @__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." }),
@@ -1776,6 +1833,11 @@ function OverviewPanel({ data, previous, onBrush }) {
1776
1833
  (data.campaigns?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1777
1834
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Email campaigns" }),
1778
1835
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Clicks are distinct subscribers. Opens are inflated by Apple Mail Privacy Protection, which fetches images on the recipient\u2019s behalf \u2014 so sort on clicks, not opens." }),
1836
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: [
1837
+ "The last two columns count what your order book records in the ",
1838
+ SEND_WINDOW_DAYS,
1839
+ " days after each send, ending early if another send lands first. They are what happened next, not what the send caused \u2014 but unlike anything GA4 can tell you about email, both the send times and the order times are exact."
1840
+ ] }),
1779
1841
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1780
1842
  SortableTable,
1781
1843
  {
@@ -1805,6 +1867,31 @@ function OverviewPanel({ data, previous, onBrush }) {
1805
1867
  numeric: true,
1806
1868
  sortValue: (c) => c.unsubscribed,
1807
1869
  render: (c) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: formatCount(c.unsubscribed) })
1870
+ },
1871
+ {
1872
+ key: "ordersAfter",
1873
+ label: "Orders after",
1874
+ numeric: true,
1875
+ sortValue: (c) => c.ordersAfter ?? null,
1876
+ exportValue: (c) => c.ordersAfter ?? null,
1877
+ render: (c) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, children: [
1878
+ c.ordersAfter === null || c.ordersAfter === void 0 ? "\u2014" : formatCount(c.ordersAfter),
1879
+ c.windowComplete === false && c.ordersAfter !== null && c.ordersAfter !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { as: "span", size: 0, muted: true, children: " so far" })
1880
+ ] })
1881
+ },
1882
+ {
1883
+ key: "revenuePerThousand",
1884
+ label: "Per 1,000 sent",
1885
+ numeric: true,
1886
+ // Per thousand, not per send: a foundry's sends run to thousands of addresses
1887
+ // against a handful of orders, so per-send lands at fractions of a cent and
1888
+ // every campaign renders as the same rounded zero.
1889
+ sortValue: (c) => revenuePerThousandSent(c),
1890
+ exportValue: (c) => revenuePerThousandSent(c),
1891
+ render: (c) => {
1892
+ const value = revenuePerThousandSent(c);
1893
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: value === null ? "\u2014" : formatMoney(value, data.currency ?? null) });
1894
+ }
1808
1895
  }
1809
1896
  ]
1810
1897
  }
@@ -2205,6 +2292,36 @@ function buyRateIndex(row, benchmark) {
2205
2292
  if (views === null || views < MIN_FAMILY_VIEWS) return null;
2206
2293
  return rate / benchmark;
2207
2294
  }
2295
+ function revenuePerThousandSent(campaign) {
2296
+ if (campaign.revenueAfter === null || campaign.revenueAfter === void 0) return null;
2297
+ if (!Number.isFinite(campaign.sent) || campaign.sent <= 0) return null;
2298
+ return campaign.revenueAfter / campaign.sent * 1e3;
2299
+ }
2300
+ function averageOrderValue(data) {
2301
+ const revenueMetric = metricOr(data.revenue, OLDER_ROUTE);
2302
+ if (revenueMetric.status === "unavailable") return revenueMetric;
2303
+ const revenue = metricSortValue(revenueMetric);
2304
+ const orders = finiteOrNull(data.ordersWithTotal);
2305
+ if (revenue === null || orders === null || orders <= 0) {
2306
+ return unavailable("not_applicable", "No order in this range carried an amount to average");
2307
+ }
2308
+ const average = revenue / orders;
2309
+ const counted = finiteOrNull(metricSortValue(metricOr(data.orders, OLDER_ROUTE)));
2310
+ const overSubset = counted !== null && counted > orders;
2311
+ return overSubset || data.revenue.status === "partial" ? partial(average, "", `Averaged over the ${formatCount(orders)} of ${formatCount(counted ?? orders)} orders that carry an amount.`) : ok(average);
2312
+ }
2313
+ function visitorsPerOrder(data) {
2314
+ const visitorMetric = metricOr(data.vercelVisitors, OLDER_ROUTE);
2315
+ const orderMetric = metricOr(data.orders, OLDER_ROUTE);
2316
+ if (visitorMetric.status === "unavailable") return visitorMetric;
2317
+ if (orderMetric.status === "unavailable") return orderMetric;
2318
+ const visitors = metricSortValue(visitorMetric);
2319
+ const orders = metricSortValue(orderMetric);
2320
+ if (visitors === null || orders === null || orders <= 0) {
2321
+ return unavailable("not_applicable", "No order in this range to divide the visitors by");
2322
+ }
2323
+ return ok(Math.round(visitors / orders));
2324
+ }
2208
2325
  function TypefaceInterestPanel({ data }) {
2209
2326
  const benchmark = catalogueRate(data.rows ?? []);
2210
2327
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
@@ -2705,7 +2822,8 @@ function ReadyReport({
2705
2822
  envelope.comparison.range.start,
2706
2823
  " to ",
2707
2824
  envelope.comparison.range.end,
2708
- ", the equivalent window immediately before this one.",
2825
+ ",",
2826
+ envelope.comparison.basis === "same-period-last-year" ? " the same window a year earlier." : " the equivalent window immediately before this one.",
2709
2827
  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." })
2710
2828
  ] }),
2711
2829
  GA4_ONLY_TABS.includes(tabId) && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CoverageRibbon, { ratio: knownShortfall(apiBaseUrl, range, range === "custom" ? custom : void 0) }),
@@ -2738,15 +2856,46 @@ function ReadyReport({
2738
2856
  ] })
2739
2857
  ] });
2740
2858
  }
2859
+ function BasisSelector({
2860
+ value,
2861
+ onChange
2862
+ }) {
2863
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: controlRow, children: [
2864
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: "Compared with" }),
2865
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2866
+ "button",
2867
+ {
2868
+ type: "button",
2869
+ style: rangeButton(value === "previous-period"),
2870
+ "aria-pressed": value === "previous-period",
2871
+ title: "The equivalent window immediately before this one",
2872
+ onClick: () => onChange("previous-period"),
2873
+ children: "Previous period"
2874
+ }
2875
+ ),
2876
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2877
+ "button",
2878
+ {
2879
+ type: "button",
2880
+ style: rangeButton(value === "same-period-last-year"),
2881
+ "aria-pressed": value === "same-period-last-year",
2882
+ title: "The same window a year earlier, shifted by 364 days so the weekdays line up",
2883
+ onClick: () => onChange("same-period-last-year"),
2884
+ children: "Last year"
2885
+ }
2886
+ )
2887
+ ] });
2888
+ }
2741
2889
  function ReportPanel({
2742
2890
  tabId,
2743
2891
  report,
2744
2892
  apiBaseUrl,
2745
2893
  range,
2746
2894
  custom,
2895
+ compare,
2747
2896
  onBrush
2748
2897
  }) {
2749
- const { state, reload } = useReport({ apiBaseUrl, report, range, custom });
2898
+ const { state, reload } = useReport({ apiBaseUrl, report, range, custom, compare });
2750
2899
  const diagnostics = useReport({
2751
2900
  apiBaseUrl,
2752
2901
  report: "diagnostics",
@@ -2840,6 +2989,7 @@ function VisitorInsightsTool(props) {
2840
2989
  const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react4.useState)(linkedRange ?? "month");
2841
2990
  const [custom, setCustom] = (0, import_react4.useState)(() => linked.from && linked.to ? { start: linked.from, end: linked.to } : { start: isoDaysAgo(30), end: isoDaysAgo(0) });
2842
2991
  const atPresent = custom.end >= isoDaysAgo(0);
2992
+ const [compare, setCompare] = (0, import_react4.useState)("previous-period");
2843
2993
  const [activePanel, setActivePanel] = (0, import_react4.useState)(linkedTab ?? "overview");
2844
2994
  import_react4.default.useEffect(() => {
2845
2995
  if (typeof window === "undefined") return;
@@ -2861,7 +3011,10 @@ function VisitorInsightsTool(props) {
2861
3011
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Container, { width: 4, padding: 4, as: "section", "aria-label": siteLabel ? `Visitor insights for ${siteLabel}` : "Visitor insights", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 5, children: [
2862
3012
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [
2863
3013
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Heading, { size: 2, children: "Visitor insights" }),
2864
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(RangeSelector, { value: range, custom, onChange: setRange, onCustomChange: setCustom })
3014
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 2, children: [
3015
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(RangeSelector, { value: range, custom, onChange: setRange, onCustomChange: setCustom }),
3016
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(BasisSelector, { value: compare, onChange: setCompare })
3017
+ ] })
2865
3018
  ] }),
2866
3019
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelTabs, { value: activePanel, onChange: setActivePanel }),
2867
3020
  range === "custom" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: contextRow, children: [
@@ -2930,6 +3083,7 @@ function VisitorInsightsTool(props) {
2930
3083
  apiBaseUrl,
2931
3084
  range,
2932
3085
  custom,
3086
+ compare,
2933
3087
  onBrush: (start, end) => {
2934
3088
  if (range !== "custom") setRangeBeforeBrush(range);
2935
3089
  setCustom({ start, end });
@@ -2944,33 +3098,6 @@ function VisitorInsightsTool(props) {
2944
3098
  );
2945
3099
  }
2946
3100
 
2947
- // src/types.ts
2948
- function estimated(value, low, high, basis) {
2949
- return { status: "estimated", value, low, high, basis };
2950
- }
2951
- function ok(value) {
2952
- return { status: "ok", value };
2953
- }
2954
- function unavailable(reason, detail) {
2955
- return { status: "unavailable", reason, detail };
2956
- }
2957
- function partial(value, coveredFrom, note) {
2958
- return { status: "partial", value, coveredFrom, note };
2959
- }
2960
- function valueOrNull(metric) {
2961
- return metric.status === "unavailable" ? null : metric.value;
2962
- }
2963
- var REPORT_NAMES = [
2964
- "measurement-health",
2965
- "acquisition",
2966
- "journey",
2967
- "typeface-interest",
2968
- "diagnostics"
2969
- ];
2970
- function isReportName(value) {
2971
- return typeof value === "string" && REPORT_NAMES.includes(value);
2972
- }
2973
-
2974
3101
  // src/core/cutover.ts
2975
3102
  var PREEXISTING = "preexisting";
2976
3103
  function toHistory(cutover) {