@liiift-studio/sanity-visitor-insights 0.63.0 → 0.65.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.js CHANGED
@@ -307,7 +307,10 @@ var MARKS = {
307
307
  "bar.fill": { key: "vercel", alpha: 1, role: "data", note: "Every proportion, comparison and funnel bar" },
308
308
  "bar.track": { key: "neutral", alpha: 0.12, role: "furniture", note: "The rail a bar sits in" },
309
309
  "bar.lost": { key: "revenue", alpha: 1, role: "data", note: "People who did not continue past a funnel rung" },
310
- "bar.seen": { key: "ga4Pageviews", alpha: 1, role: "data", note: "The share of a complete count that the lossy source saw" }
310
+ "bar.seen": { key: "ga4Pageviews", alpha: 1, role: "data", note: "The share of a complete count that the lossy source saw" },
311
+ "estimate.dot": { key: "ga4Pageviews", alpha: 1, role: "data", note: "One independent estimate of how much GA4 sees" },
312
+ "estimate.interval": { key: "ga4Pageviews", alpha: 0.35, role: "fill", boundary: "estimate.dot", note: "How wide the sample leaves that estimate \u2014 the dot is its own boundary" },
313
+ "estimate.rule": { key: "neutral", alpha: 0.45, role: "furniture", note: "The 100% reference a capture rate is read against" }
311
314
  };
312
315
  function mark(name) {
313
316
  const m = MARKS[name];
@@ -846,6 +849,50 @@ function ContainmentBar({
846
849
  ] })
847
850
  ] });
848
851
  }
852
+ function EstimateDotPlot({
853
+ estimates,
854
+ labelFor,
855
+ intervalFor
856
+ }) {
857
+ if (estimates.length === 0) return null;
858
+ const intervals = estimates.map((e) => intervalFor(e));
859
+ const top = Math.max(1, ...estimates.map((e) => e.rate), ...intervals.map((i) => i.high));
860
+ const pct = (v) => `${Math.max(0, Math.min(1, v / top)) * 100}%`;
861
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 3, children: [
862
+ estimates.map((estimate, index) => {
863
+ const interval = intervals[index];
864
+ const wide = interval.high - interval.low > 0.4;
865
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
866
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: barHeader, children: [
867
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, children: labelFor(estimate.basis) }),
868
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "medium", children: formatPercent(estimate.rate, 0) })
869
+ ] }),
870
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "aria-hidden": "true", style: estimateTrack, children: [
871
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...estimateRule, left: pct(1) } }),
872
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
873
+ "div",
874
+ {
875
+ style: {
876
+ ...estimateInterval,
877
+ left: pct(interval.low),
878
+ width: `calc(${pct(interval.high)} - ${pct(interval.low)})`
879
+ }
880
+ }
881
+ ),
882
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...estimateDot, left: pct(estimate.rate) } })
883
+ ] }),
884
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
885
+ formatCount(estimate.observed),
886
+ " of ",
887
+ formatCount(estimate.actual),
888
+ wide && " \u2014 too small a sample to lean on",
889
+ estimate.rate > 1.05 && " \u2014 over 100%, so GA4 is counting more than the source it is checked against. That is usually a tag firing twice, not extra traffic."
890
+ ] })
891
+ ] }, estimate.basis);
892
+ }),
893
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "Each is a separate way of asking the same question. Where they disagree, the spread is the answer\u2019s uncertainty \u2014 the rule marks 100%." })
894
+ ] });
895
+ }
849
896
  function SortableTable({
850
897
  caption,
851
898
  columns,
@@ -1063,6 +1110,35 @@ var splitGrid = {
1063
1110
  gap: 24,
1064
1111
  alignItems: "start"
1065
1112
  };
1113
+ var estimateTrack = {
1114
+ position: "relative",
1115
+ height: 18,
1116
+ borderRadius: 2,
1117
+ background: mark("bar.track")
1118
+ };
1119
+ var estimateRule = {
1120
+ position: "absolute",
1121
+ top: 0,
1122
+ bottom: 0,
1123
+ width: 1,
1124
+ background: mark("estimate.rule")
1125
+ };
1126
+ var estimateInterval = {
1127
+ position: "absolute",
1128
+ top: 6,
1129
+ height: 6,
1130
+ borderRadius: 3,
1131
+ background: mark("estimate.interval")
1132
+ };
1133
+ var estimateDot = {
1134
+ position: "absolute",
1135
+ top: 4,
1136
+ width: 10,
1137
+ height: 10,
1138
+ marginLeft: -5,
1139
+ borderRadius: "50%",
1140
+ background: mark("estimate.dot")
1141
+ };
1066
1142
  var filterInput = {
1067
1143
  font: "inherit",
1068
1144
  fontSize: "0.85em",
@@ -1884,6 +1960,15 @@ function describeRank(rank) {
1884
1960
  return `${ordinal(rank.rank)} best of the last ${rank.of} weeks`;
1885
1961
  }
1886
1962
 
1963
+ // src/core/capture.ts
1964
+ function samplingInterval(estimate) {
1965
+ const n = estimate.actual;
1966
+ if (!Number.isFinite(n) || n <= 0) return { low: estimate.rate, high: estimate.rate };
1967
+ const p = Math.min(1, Math.max(0, estimate.rate));
1968
+ const error = 2 * Math.sqrt(Math.max(p * (1 - p), 0.02) / n);
1969
+ return { low: Math.max(0, estimate.rate - error), high: estimate.rate + error };
1970
+ }
1971
+
1887
1972
  // src/studio/panels.tsx
1888
1973
  var import_jsx_runtime3 = require("react/jsx-runtime");
1889
1974
  function metricSortValue(metric) {
@@ -2074,38 +2159,62 @@ function OverviewPanel({ data, previous, onBrush }) {
2074
2159
  source: "GA4",
2075
2160
  complete: true,
2076
2161
  unit: "percent",
2077
- // Fixed, not scaled to its own best day — otherwise a site at a flat 20%
2078
- // draws a full-height line and reads as healthy.
2079
- domain: [0, 1],
2162
+ // Fixed at the bottom, open at the top.
2163
+ //
2164
+ // The lower bound is fixed so a site at a flat 20% cannot scale itself
2165
+ // to full height and read as healthy. The UPPER bound is not clamped,
2166
+ // because over-counting is a real fault with its own interpretation
2167
+ // branch — and the card further down this very file refuses the same
2168
+ // clamp in prose, saying `Math.min(1, …)` rendered a tag reporting
2169
+ // 250% of reality as a flat 100% under the heading "How much GA4 is
2170
+ // seeing", i.e. perfect. One quantity, two opposite policies, one
2171
+ // file: the row drew a double-firing tag as a healthy flat line while
2172
+ // the sentence beside it called the over-count out.
2173
+ domain: [0, Math.max(1, ...(data.crossSource ?? []).map((d) => d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? d.ga4Pageviews / d.vercelPageviews : 0))],
2080
2174
  points: (data.crossSource ?? []).map((d) => ({
2081
2175
  date: d.date,
2082
- value: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? Math.min(1, d.ga4Pageviews / d.vercelPageviews) : null
2176
+ value: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? d.ga4Pageviews / d.vercelPageviews : null
2083
2177
  })),
2084
2178
  // The most valuable ghost in the chart: a collapse reads as a step away
2085
2179
  // from last period's flat line, whether or not the detector fired.
2086
2180
  comparison: (previous?.crossSource ?? []).map((d) => ({
2087
2181
  date: d.date,
2088
- value: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? Math.min(1, d.ga4Pageviews / d.vercelPageviews) : null
2182
+ value: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? d.ga4Pageviews / d.vercelPageviews : null
2089
2183
  }))
2090
2184
  }] : [],
2091
- ...data.crossSource?.some((d) => d.revenue !== null) ? [{
2092
- key: "revenue",
2093
- label: "Revenue",
2094
- source: "Sanity",
2095
- complete: true,
2096
- unit: "money",
2097
- // A sale is an event on a date, not a level that varies day to day.
2098
- mark: "events",
2099
- points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.revenue }))
2100
- }] : [{
2185
+ // ORDERS ALWAYS, revenue only when it covers every order.
2186
+ //
2187
+ // This used to draw revenue whenever ANY day had a revenue figure, and
2188
+ // otherwise orders — never both. Combined with the server filling a day's
2189
+ // revenue as `revenueByDate[date] ?? 0`, that meant a day carrying a real
2190
+ // order with no recorded amount arrived as a measured ZERO, and the row drew
2191
+ // a zero-tick on it: the mark whose own comment says "measured, and it was
2192
+ // nothing".
2193
+ //
2194
+ // At Darden 11 of 69 orders carry an amount, so the flagship chart was
2195
+ // asserting that no sale happened on 58 days that had sales — the package's
2196
+ // founding distinction inverted inside its most prominent encoding. Orders
2197
+ // are exact and complete whatever the amounts say, so they are the row that
2198
+ // is always honest.
2199
+ {
2101
2200
  key: "orders",
2102
2201
  label: "Orders",
2103
2202
  source: "Sanity",
2104
2203
  complete: true,
2105
2204
  unit: "count",
2205
+ // A sale is an event on a date, not a level that varies day to day.
2106
2206
  mark: "events",
2107
2207
  points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.orders }))
2108
- }]
2208
+ },
2209
+ ...revenueCoversEveryOrder(data) ? [{
2210
+ key: "revenue",
2211
+ label: "Revenue",
2212
+ source: "Sanity",
2213
+ complete: true,
2214
+ unit: "money",
2215
+ mark: "events",
2216
+ points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.revenue }))
2217
+ }] : []
2109
2218
  ]
2110
2219
  }
2111
2220
  ),
@@ -2363,18 +2472,14 @@ function DataHealthPanel({ data, diagnostics }) {
2363
2472
  ),
2364
2473
  ((data.capture?.estimates?.length ?? 0) > 0 || data.estimatedSessions?.status === "estimated") && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2365
2474
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "How much GA4 is seeing" }),
2366
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: cardGrid, children: (data.capture?.estimates ?? []).map((estimate) => /* @__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: [
2367
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: estimate.basis === "orders" ? "Checked against your orders" : estimate.basis === "email" ? "Checked against email clicks" : "Checked against Vercel" }),
2368
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 4, children: formatPercent(estimate.rate, 0) }),
2369
- estimate.rate > 1.05 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, children: "Above 100%: GA4 is counting more than the source it is measured against. That is usually a tag firing twice, not extra traffic." }),
2370
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
2371
- formatCount(estimate.observed),
2372
- " of ",
2373
- formatCount(estimate.actual),
2374
- ". ",
2375
- estimate.note
2376
- ] })
2377
- ] }) }, estimate.basis)) }),
2475
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2476
+ EstimateDotPlot,
2477
+ {
2478
+ estimates: data.capture?.estimates ?? [],
2479
+ labelFor: (basis) => basis === "orders" ? "Checked against your orders" : basis === "email" ? "Checked against email clicks" : "Checked against Vercel",
2480
+ intervalFor: samplingInterval
2481
+ }
2482
+ ),
2378
2483
  data.capture?.discrepancy && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
2379
2484
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, weight: "medium", children: "The sources disagree, and that is informative" }),
2380
2485
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: data.capture.discrepancy })
@@ -2679,6 +2784,12 @@ function SegmentTable({ segments, dimension }) {
2679
2784
  ] }) })
2680
2785
  ] });
2681
2786
  }
2787
+ function revenueCoversEveryOrder(data) {
2788
+ const counted = metricSortValue(data.orders);
2789
+ const withTotal = finiteOrNull(data.ordersWithTotal);
2790
+ if (counted === null || withTotal === null) return false;
2791
+ return counted === withTotal;
2792
+ }
2682
2793
  function JourneyPanel({ data }) {
2683
2794
  const segments = data.segments ?? [];
2684
2795
  const allSteps = data.steps ?? [];