@liiift-studio/sanity-visitor-insights 0.27.0 → 0.28.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, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-_SBvGBJx.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-_SBvGBJx.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-D1A9JY7h.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-D1A9JY7h.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -117,7 +117,7 @@ declare function formatCount(value: number): string;
117
117
  declare function formatPercent(ratio: number, digits?: number): string;
118
118
  /** Props for MetricFigure. */
119
119
  /** Format a money value in the site's currency, falling back to a plain number. */
120
- declare function formatMoney(value: number, currency: string | null): string;
120
+ declare function formatMoney(value: number, currency: string | null, fractionDigits?: 0 | 2): string;
121
121
  /** Props for Delta. */
122
122
  interface DeltaProps {
123
123
  /** This period's value, or null when it could not be measured. */
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-_SBvGBJx.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-_SBvGBJx.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-D1A9JY7h.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-D1A9JY7h.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -117,7 +117,7 @@ declare function formatCount(value: number): string;
117
117
  declare function formatPercent(ratio: number, digits?: number): string;
118
118
  /** Props for MetricFigure. */
119
119
  /** Format a money value in the site's currency, falling back to a plain number. */
120
- declare function formatMoney(value: number, currency: string | null): string;
120
+ declare function formatMoney(value: number, currency: string | null, fractionDigits?: 0 | 2): string;
121
121
  /** Props for Delta. */
122
122
  interface DeltaProps {
123
123
  /** This period's value, or null when it could not be measured. */
package/dist/index.js CHANGED
@@ -219,19 +219,25 @@ var REASON_TEXT = {
219
219
  source_error: "Source did not respond",
220
220
  not_applicable: "Does not apply to this site"
221
221
  };
222
+ var LOCALE = "en-GB";
222
223
  function formatCount(value) {
223
- return new Intl.NumberFormat("en-GB").format(Math.round(value));
224
+ return new Intl.NumberFormat(LOCALE).format(Math.round(value));
224
225
  }
225
226
  function formatPercent(ratio, digits = 0) {
226
227
  return `${(ratio * 100).toFixed(digits)}%`;
227
228
  }
228
- function formatMoney(value, currency) {
229
+ function formatMoney(value, currency, fractionDigits = 0) {
229
230
  if (!currency) return formatCount(Math.round(value));
230
231
  try {
231
- return new Intl.NumberFormat(void 0, {
232
+ return new Intl.NumberFormat(LOCALE, {
232
233
  style: "currency",
233
234
  currency,
234
- maximumFractionDigits: value >= 1e3 ? 0 : 2
235
+ // One precision for the whole column, chosen by the caller, not by each value's own size.
236
+ // Switching at 1,000 put "$850.00" and "$1,200" in adjacent rows, and printed an axis
237
+ // reading "$1,000" over a baseline reading "$0.00" — the mismatch the timeline's own
238
+ // comment claims to have fixed by routing both ends through this function.
239
+ maximumFractionDigits: fractionDigits,
240
+ minimumFractionDigits: fractionDigits
235
241
  }).format(value);
236
242
  } catch {
237
243
  return `${formatCount(Math.round(value))} ${currency}`;
@@ -1203,7 +1209,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1203
1209
  }
1204
1210
  const sentences = [];
1205
1211
  if (excess > 0) {
1206
- sentences.push(`${row.shortfall.source} counted ${formatValue(Math.round(excess), row.unit, currency)} MORE ${row.label.toLowerCase()} than ${row.source} on some days, which usually means a tag firing twice rather than extra traffic.`);
1212
+ sentences.push(`${row.shortfall.source} counted ${formatValue(Math.round(excess), row.unit, currency)} more ${row.label.toLowerCase()} than ${row.source} on some days, which usually means a tag firing twice rather than extra traffic.`);
1207
1213
  }
1208
1214
  if (gap <= 0) return sentences;
1209
1215
  const total = `${row.shortfall.source} missed ${formatValue(Math.round(gap), row.unit, currency)} ${row.label.toLowerCase()} over this period`;
@@ -1222,7 +1228,6 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1222
1228
  setHoverIndex(null);
1223
1229
  setBrushAnchor(null);
1224
1230
  setBrushed(null);
1225
- setTooShort(false);
1226
1231
  },
1227
1232
  onPointerDown: (event) => {
1228
1233
  if (!onBrush) return;
@@ -1274,7 +1279,8 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1274
1279
  event.preventDefault();
1275
1280
  setHoverIndex(next);
1276
1281
  if (event.shiftKey && onBrush) {
1277
- const anchor = brushed ? brushed[0] : current;
1282
+ const anchor = brushAnchor ?? current;
1283
+ if (brushAnchor === null) setBrushAnchor(current);
1278
1284
  setBrushed([Math.min(anchor, next), Math.max(anchor, next)]);
1279
1285
  }
1280
1286
  },
@@ -1296,7 +1302,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1296
1302
  const pitch = plotWidth / Math.max(1, dates.length - 1);
1297
1303
  const stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch));
1298
1304
  const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value !== 0) : [];
1299
- const zeroes = row.mark === "events" ? row.points.filter((p) => p.value === 0) : [];
1305
+ const zeroes = row.mark === "events" && pitch >= 6 ? row.points.filter((p) => p.value === 0) : [];
1300
1306
  const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
1301
1307
  const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints) ?? "" : "";
1302
1308
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
@@ -1672,7 +1678,7 @@ function OverviewPanel({ data, previous, onBrush }) {
1672
1678
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1673
1679
  ChartData,
1674
1680
  {
1675
- note: "A negative miss means GA4 counted MORE than Vercel that day \u2014 usually a tag firing twice, not extra traffic.",
1681
+ note: "A negative miss means GA4 counted more than Vercel that day \u2014 usually a tag firing twice, not extra traffic.",
1676
1682
  label: "Show these figures as a table",
1677
1683
  rows: data.crossSource ?? [],
1678
1684
  rowKey: (d) => d.date,
@@ -2031,16 +2037,23 @@ function AcquisitionPanel({ data, previous }) {
2031
2037
  * total and the money column is empty.
2032
2038
  */
2033
2039
  key: "brought",
2034
- label: "Revenue (est.)",
2040
+ // The hedge belongs on the SPLIT, not on the money: the total is exact and the
2041
+ // division of it is the estimate. "(est.)" on a revenue header implied the reverse.
2042
+ label: "Revenue, split",
2035
2043
  numeric: true,
2036
2044
  sortValue: (row) => finiteOrNull(row.revenueShare),
2037
- exportValue: (row) => row.apportionedRevenue === null ? null : formatMoney(row.apportionedRevenue, data.currency ?? null),
2045
+ exportValue: (row) => {
2046
+ const money = finiteOrNull(row.apportionedRevenue);
2047
+ return money === null ? null : formatMoney(money, data.currency ?? null);
2048
+ },
2038
2049
  render: (row) => {
2039
- if (row.revenueShare === null) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" });
2050
+ const share = finiteOrNull(row.revenueShare);
2051
+ if (share === null || share === 0) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "\u2014" });
2052
+ const money = finiteOrNull(row.apportionedRevenue);
2040
2053
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 1, children: [
2041
- row.apportionedRevenue !== null && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatMoney(row.apportionedRevenue, data.currency ?? null) }),
2054
+ money !== null && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: formatMoney(money, data.currency ?? null) }),
2042
2055
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
2043
- formatPercent(row.revenueShare, 0),
2056
+ formatPercent(share, 0),
2044
2057
  " of tracked sales"
2045
2058
  ] })
2046
2059
  ] });
@@ -2049,21 +2062,27 @@ function AcquisitionPanel({ data, previous }) {
2049
2062
  ]
2050
2063
  }
2051
2064
  ),
2052
- data.trackedPurchases > 0 && data.actualRevenue !== null && // Says exactly what was combined and what was estimated. The money in that column
2065
+ data.splitIsSound === true && finiteOrNull(data.actualRevenue) !== null && // Says exactly what was combined and what was estimated. The money in that column
2053
2066
  // is Sanity's real total spread across GA4's split — right in scale, and derived,
2054
2067
  // which is a different claim from measured. Saying so here is what makes the
2055
2068
  // column usable rather than another figure to distrust.
2056
2069
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [
2057
2070
  "Revenue is ",
2058
- formatMoney(data.actualRevenue, data.currency ?? null),
2071
+ formatMoney(finiteOrNull(data.actualRevenue) ?? 0, data.currency ?? null),
2059
2072
  " from",
2060
2073
  " ",
2061
- data.actualOrders === null ? "your orders" : `${formatCount(data.actualOrders)} orders`,
2074
+ finiteOrNull(data.actualOrders) === null ? "your orders" : `${formatCount(data.actualOrders)} orders`,
2062
2075
  " in Sanity, split across the ",
2063
- formatCount(data.trackedPurchases),
2076
+ formatCount(data.trackedPurchases ?? 0),
2064
2077
  " purchase",
2065
2078
  data.trackedPurchases === 1 ? "" : "s",
2066
- " Google Analytics attributed to a source. Google Analytics sees a fraction of your traffic, but it loses it fairly evenly across channels \u2014 so the split is far more trustworthy than its own totals. Treat these as proportions with a real total behind them, not as measured takings per channel."
2079
+ " Google Analytics attributed to a source. Google Analytics sees a fraction of your traffic, so treat this as a proportion with a real total behind it rather than measured takings per channel. Two things to hold in mind: it credits whichever source a buyer arrived from on the visit they bought, which for a considered purchase is often a bookmark rather than the blog that first sent them; and its loss is not perfectly even across channels \u2014 ad-blocking runs high among designers, so earned referrals and direct visits are likelier to be under-credited than paid clicks.",
2080
+ finiteOrNull(data.shownPurchases) !== null && (data.shownPurchases ?? 0) < (data.trackedPurchases ?? 0) && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
2081
+ " ",
2082
+ "The rows above account for ",
2083
+ formatCount(data.shownPurchases ?? 0),
2084
+ " of those sales; the rest came from sources outside this list, so this column does not add up to the whole."
2085
+ ] })
2067
2086
  ] })
2068
2087
  ] }),
2069
2088
  data.rowsWithheld && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(NoticeList, { notices: ["GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality."] })
@@ -2146,24 +2165,31 @@ function JourneyPanel({ data }) {
2146
2165
  ] })
2147
2166
  ] });
2148
2167
  }
2149
- function medianOf(values) {
2150
- const usable = values.filter((v) => v !== null && Number.isFinite(v) && v > 0).sort((a, b) => a - b);
2151
- if (usable.length < 3) return null;
2152
- const middle = Math.floor(usable.length / 2);
2153
- return usable.length % 2 === 0 ? (usable[middle - 1] + usable[middle]) / 2 : usable[middle];
2168
+ function catalogueRate(rows) {
2169
+ let views = 0;
2170
+ let sales = 0;
2171
+ for (const row of rows) {
2172
+ const viewed = metricSortValue(row.viewed);
2173
+ const bought = metricSortValue(row.bought);
2174
+ if (viewed === null || bought === null) continue;
2175
+ views += viewed;
2176
+ sales += bought;
2177
+ }
2178
+ return views > 0 && sales > 0 ? sales / views : null;
2154
2179
  }
2155
- function buyRateIndex(row, median) {
2180
+ function buyRateIndex(row, benchmark) {
2156
2181
  const rate = row.buyRate;
2157
- if (median === null || median <= 0 || rate === null || rate === void 0 || !Number.isFinite(rate)) return null;
2158
- return rate / median;
2182
+ if (benchmark === null || benchmark <= 0) return null;
2183
+ if (rate === null || rate === void 0 || !Number.isFinite(rate) || rate < 0) return null;
2184
+ return rate / benchmark;
2159
2185
  }
2160
2186
  function TypefaceInterestPanel({ data }) {
2161
- const medianBuyRate = medianOf((data.rows ?? []).map((row) => finiteOrNull(row.buyRate)));
2187
+ const benchmark = catalogueRate(data.rows ?? []);
2162
2188
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
2163
2189
  /* @__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.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: data.interpretationNote }) }),
2164
2190
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
2165
2191
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Engagement by typeface" }),
2166
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Sort by any column. \u201CSells vs catalogue\u201D compares each family\u2019s orders-per-view against the median family, which is the part of that ratio that holds even though Google Analytics only sees a fraction of the views. It ranks families against each other; it is not the share of visitors who buy, and at these order volumes a family with one or two sales will swing a long way." }),
2192
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Sort by any column. \u201CSells vs catalogue\u201D compares each family\u2019s sales-per-view against the catalogue as a whole \u2014 1.0\xD7 is average, and sorting up finds the families that are looked at and do not sell. It is a comparison between your families, not the share of visitors who buy: Google Analytics sees only a fraction of the views, which moves every family together and so cancels out here. With one or two sales a family will still swing a long way, so read it alongside the order counts beside it." }),
2167
2193
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2168
2194
  SortableTable,
2169
2195
  {
@@ -2244,24 +2270,26 @@ function TypefaceInterestPanel({ data }) {
2244
2270
  key: "buyRate",
2245
2271
  label: "Sells vs catalogue",
2246
2272
  numeric: true,
2247
- sortValue: (row) => finiteOrNull(row.buyRate),
2273
+ // The INDEX, so a row rendered as a dash cannot sort above a row with a value.
2274
+ // Sorting on the raw rate returned 0 — a real number — for a family whose
2275
+ // cell showed "—", so ascending put a block of dashes at the top of the
2276
+ // table, which is the one thing this file's header forbids.
2277
+ sortValue: (row) => buyRateIndex(row, benchmark),
2248
2278
  exportValue: (row) => {
2249
- const index = buyRateIndex(row, medianBuyRate);
2250
- return index === null ? null : `${index.toFixed(1)}x`;
2279
+ const index = buyRateIndex(row, benchmark);
2280
+ return index === null ? null : `${index.toFixed(1)}x catalogue`;
2251
2281
  },
2252
2282
  render: (row) => {
2253
- const index = buyRateIndex(row, medianBuyRate);
2283
+ const index = buyRateIndex(row, benchmark);
2254
2284
  if (index === null) {
2255
2285
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, "aria-label": `${row.typeface} not comparable`, children: "\u2014" });
2256
2286
  }
2287
+ if (index === 0) {
2288
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: "No sales" });
2289
+ }
2257
2290
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 1, children: [
2258
2291
  index.toFixed(1),
2259
- "\xD7",
2260
- " ",
2261
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: { opacity: 0.6, fontSize: "0.85em" }, children: [
2262
- index >= 1.15 ? "above" : index <= 0.85 ? "below" : "at",
2263
- " median"
2264
- ] })
2292
+ "\xD7 catalogue"
2265
2293
  ] });
2266
2294
  }
2267
2295
  },
@@ -2608,9 +2636,10 @@ function CoverageRibbon({ ratio }) {
2608
2636
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 3, radius: 2, tone: ratio > 0.6 ? "critical" : "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Text, { size: 1, children: [
2609
2637
  "Every figure on this tab comes from Google Analytics, which is seeing about ",
2610
2638
  seen,
2611
- "% of this site\u2019s traffic in this window. Read them as at least ",
2612
- seen === 0 ? "undercounts" : `${seen}% of the real numbers`,
2613
- ", not as what happened. Data health explains why."
2639
+ "% of this site\u2019s traffic in this window \u2014 so the real numbers are roughly",
2640
+ " ",
2641
+ seen > 0 ? `${(100 / seen).toFixed(1)}\xD7` : "many times",
2642
+ " these. Data health explains why."
2614
2643
  ] }) });
2615
2644
  }
2616
2645
  function ReadyReport({