@liiift-studio/sanity-visitor-insights 0.17.0 → 0.18.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
@@ -342,22 +342,43 @@ interface SeriesPoint {
342
342
  }
343
343
  /** How a value should be written out. */
344
344
  type SeriesUnit = 'count' | 'money' | 'percent';
345
- /** One row of the chart. */
345
+ /**
346
+ * One row of the chart: one answer, with what a lossier source saw underneath it.
347
+ *
348
+ * A row shows a SINGLE line by default. Two peer lines make the reader reconcile before they get
349
+ * an answer, and at a glance a foundry owner wants "how much", not "here are two measurements that
350
+ * disagree". The disagreement is still drawn — as a filled region, and in full on hover — because
351
+ * hiding it entirely would switch off the alarm: the 24 August collapse was visible precisely
352
+ * because two lines came apart.
353
+ */
346
354
  interface Series {
347
355
  key: string;
348
356
  label: string;
349
- /** Which upstream this came from, shown so the reader can weigh it. */
357
+ /** Which upstream the LINE came from. */
350
358
  source: 'GA4' | 'Vercel' | 'Sanity' | 'Mailchimp';
351
- /**
352
- * Whether the source sees everything. GA4 does not; the others do.
353
- * Drives the dashed stroke and the uncertainty band rather than a footnote nobody reads.
354
- */
359
+ /** Whether the line's source sees everything. Drives the stroke and the wording. */
355
360
  complete: boolean;
356
361
  unit: SeriesUnit;
357
362
  points: SeriesPoint[];
358
363
  /**
359
- * Multiplier from observed to estimated-true, for a lossy series. 1 means no correction known.
360
- * Drawn as a band above the line, never as a replacement for it — the measured line stays.
364
+ * What a lossier source saw of the same thing.
365
+ *
366
+ * Drawn as a filled region between the two, NOT as a symmetric uncertainty band. There is no
367
+ * doubt about the traffic here: Vercel counted it server-side. What is uncertain is how much of
368
+ * it the analytics could see, and the honest way to draw that is the area it missed — a
369
+ * quantity, visible to scale, rather than a percentage on another tab.
370
+ */
371
+ shortfall?: {
372
+ label: string;
373
+ source: Series['source'];
374
+ points: SeriesPoint[];
375
+ };
376
+ /**
377
+ * Multiplier from observed to estimated-true, where the line itself is the lossy source.
378
+ *
379
+ * This one IS a symmetric band, because it is genuine uncertainty rather than a known blind
380
+ * spot. The two must not look alike: one says "we do not know exactly", the other says "we know
381
+ * exactly, and this much was invisible".
361
382
  */
362
383
  grossUpFactor?: number;
363
384
  }
package/dist/index.d.ts CHANGED
@@ -342,22 +342,43 @@ interface SeriesPoint {
342
342
  }
343
343
  /** How a value should be written out. */
344
344
  type SeriesUnit = 'count' | 'money' | 'percent';
345
- /** One row of the chart. */
345
+ /**
346
+ * One row of the chart: one answer, with what a lossier source saw underneath it.
347
+ *
348
+ * A row shows a SINGLE line by default. Two peer lines make the reader reconcile before they get
349
+ * an answer, and at a glance a foundry owner wants "how much", not "here are two measurements that
350
+ * disagree". The disagreement is still drawn — as a filled region, and in full on hover — because
351
+ * hiding it entirely would switch off the alarm: the 24 August collapse was visible precisely
352
+ * because two lines came apart.
353
+ */
346
354
  interface Series {
347
355
  key: string;
348
356
  label: string;
349
- /** Which upstream this came from, shown so the reader can weigh it. */
357
+ /** Which upstream the LINE came from. */
350
358
  source: 'GA4' | 'Vercel' | 'Sanity' | 'Mailchimp';
351
- /**
352
- * Whether the source sees everything. GA4 does not; the others do.
353
- * Drives the dashed stroke and the uncertainty band rather than a footnote nobody reads.
354
- */
359
+ /** Whether the line's source sees everything. Drives the stroke and the wording. */
355
360
  complete: boolean;
356
361
  unit: SeriesUnit;
357
362
  points: SeriesPoint[];
358
363
  /**
359
- * Multiplier from observed to estimated-true, for a lossy series. 1 means no correction known.
360
- * Drawn as a band above the line, never as a replacement for it — the measured line stays.
364
+ * What a lossier source saw of the same thing.
365
+ *
366
+ * Drawn as a filled region between the two, NOT as a symmetric uncertainty band. There is no
367
+ * doubt about the traffic here: Vercel counted it server-side. What is uncertain is how much of
368
+ * it the analytics could see, and the honest way to draw that is the area it missed — a
369
+ * quantity, visible to scale, rather than a percentage on another tab.
370
+ */
371
+ shortfall?: {
372
+ label: string;
373
+ source: Series['source'];
374
+ points: SeriesPoint[];
375
+ };
376
+ /**
377
+ * Multiplier from observed to estimated-true, where the line itself is the lossy source.
378
+ *
379
+ * This one IS a symmetric band, because it is genuine uncertainty rather than a known blind
380
+ * spot. The two must not look alike: one says "we do not know exactly", the other says "we know
381
+ * exactly, and this much was invisible".
361
382
  */
362
383
  grossUpFactor?: number;
363
384
  }
package/dist/index.js CHANGED
@@ -913,6 +913,7 @@ function tickLabel(date) {
913
913
  }
914
914
  function CrossSourceTimeline({ series, markers = [], currency }) {
915
915
  const [hoverIndex, setHoverIndex] = (0, import_react3.useState)(null);
916
+ const [pinned, setPinned] = (0, import_react3.useState)(false);
916
917
  const frameRef = (0, import_react3.useRef)(null);
917
918
  const dates = (0, import_react3.useMemo)(() => {
918
919
  const all = /* @__PURE__ */ new Set();
@@ -940,6 +941,7 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
940
941
  );
941
942
  if (dates.length < 3 || series.length === 0 || !x) return null;
942
943
  const hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null;
944
+ const revealed = pinned || hoverIndex !== null;
943
945
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
944
946
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: frameStyle, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
945
947
  "svg",
@@ -960,16 +962,24 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
960
962
  const peak = (0, import_d3_array.max)(values) ?? 0;
961
963
  const grossed = row.grossUpFactor && row.grossUpFactor > 1 ? peak * row.grossUpFactor : peak;
962
964
  const y = (0, import_d3_scale.scaleLinear)().domain([0, grossed || 1]).nice().range([bottom, top]);
965
+ const at = (p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`));
963
966
  const defined = (p) => p.value !== null;
964
- const lineGen = (0, import_d3_shape.line)().defined(defined).x((p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`))).y((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
965
- const bandGen = (0, import_d3_shape.area)().defined(defined).x((p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`))).y0((p) => y(p.value)).y1((p) => y(p.value * row.grossUpFactor)).curve(import_d3_shape.curveMonotoneX);
967
+ const lineGen = (0, import_d3_shape.line)().defined(defined).x(at).y((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
968
+ const shortfallPoints = row.shortfall?.points ?? [];
969
+ const byDate = new Map(shortfallPoints.map((p) => [p.date, p.value]));
970
+ const gapGen = (0, import_d3_shape.area)().defined((p) => p.value !== null && byDate.get(p.date) != null).x(at).y0((p) => y(byDate.get(p.date))).y1((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
971
+ const bandGen = (0, import_d3_shape.area)().defined(defined).x(at).y0((p) => y(p.value)).y1((p) => y(p.value * row.grossUpFactor)).curve(import_d3_shape.curveMonotoneX);
966
972
  const path = lineGen(row.points) ?? "";
973
+ const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
967
974
  const band = row.grossUpFactor && row.grossUpFactor > 1 ? bandGen(row.points) ?? "" : "";
975
+ const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints.filter((p) => p.value !== null)) ?? "" : "";
968
976
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
969
977
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: bottom, y2: bottom, stroke: "currentColor", strokeWidth: 0.15, opacity: 0.25 }),
970
978
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER - 4, y: top + 5, textAnchor: "end", fontSize: 3, fill: "currentColor", opacity: 0.55, children: formatValue(grossed, row.unit, currency) }),
971
979
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER - 4, y: bottom, textAnchor: "end", fontSize: 3, fill: "currentColor", opacity: 0.55, children: "0" }),
980
+ gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: revealed ? 0.16 : 0.09 }),
972
981
  band && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: band, fill: "currentColor", opacity: 0.1 }),
982
+ shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 0.35, strokeDasharray: "1.5 1", opacity: 0.75 }),
973
983
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
974
984
  "path",
975
985
  {
@@ -1039,11 +1049,18 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1039
1049
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, weight: "medium", children: hoveredDate ? tickLabel(/* @__PURE__ */ new Date(`${hoveredDate}T00:00:00Z`)) : "Hover the chart to read a day" }),
1040
1050
  hoveredDate && series.map((row) => {
1041
1051
  const point = row.points.find((p) => p.date === hoveredDate);
1052
+ const seen = row.shortfall?.points.find((p) => p.date === hoveredDate);
1042
1053
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
1043
1054
  row.label,
1044
1055
  ":",
1045
1056
  " ",
1046
- point && point.value !== null ? formatValue(point.value, row.unit, currency) : "\u2014"
1057
+ point && point.value !== null ? formatValue(point.value, row.unit, currency) : "\u2014",
1058
+ seen && seen.value !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1059
+ " \xB7 ",
1060
+ row.shortfall?.source,
1061
+ " saw ",
1062
+ formatValue(seen.value, row.unit, currency)
1063
+ ] })
1047
1064
  ] }, row.key);
1048
1065
  }),
1049
1066
  hoveredDate && markers.filter((m) => m.date === hoveredDate).map((m) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, children: [
@@ -1052,6 +1069,19 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1052
1069
  ] }, m.label))
1053
1070
  ] }),
1054
1071
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: legendRow2, children: [
1072
+ series.some((row) => row.shortfall) && // Hover is not available on touch and not reachable by keyboard, so the reveal
1073
+ // has an explicit control too. Without it the detail would exist only for people
1074
+ // using a mouse.
1075
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1076
+ "button",
1077
+ {
1078
+ type: "button",
1079
+ style: revealButton,
1080
+ "aria-pressed": pinned,
1081
+ onClick: () => setPinned((current) => !current),
1082
+ children: pinned ? "Hide what each source saw" : "Show what each source saw"
1083
+ }
1084
+ ),
1055
1085
  series.map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: [
1056
1086
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { "aria-hidden": "true", children: row.complete ? "\u2500\u2500\u2500" : "\u254C\u254C\u254C" }),
1057
1087
  " ",
@@ -1060,7 +1090,8 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1060
1090
  row.source,
1061
1091
  ")"
1062
1092
  ] }, row.key)),
1063
- series.some((row) => !row.complete) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Dashed means the source misses things. The shaded band is where the figure probably sits." }),
1093
+ series.some((row) => row.shortfall) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "The shaded area is what your analytics did not see. It is a quantity, not a margin of error \u2014 the traffic happened, GA4 just missed it." }),
1094
+ series.some((row) => !row.complete && row.grossUpFactor) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "A dashed line is a lossy source; the band above it is where the true figure probably sits." }),
1064
1095
  markers.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Vertical rules mark campaign sends." })
1065
1096
  ] })
1066
1097
  ] });
@@ -1073,6 +1104,18 @@ var readoutRow = {
1073
1104
  alignItems: "baseline",
1074
1105
  minHeight: 18
1075
1106
  };
1107
+ var revealButton = {
1108
+ appearance: "none",
1109
+ background: "transparent",
1110
+ border: "1px solid var(--card-border-color, rgba(128,128,128,0.3))",
1111
+ borderRadius: 3,
1112
+ color: "inherit",
1113
+ font: "inherit",
1114
+ fontSize: "0.8em",
1115
+ padding: "3px 8px",
1116
+ cursor: "pointer",
1117
+ whiteSpace: "nowrap"
1118
+ };
1076
1119
  var legendRow2 = {
1077
1120
  display: "flex",
1078
1121
  gap: 14,
@@ -1139,6 +1182,7 @@ function MeasurementHealthPanel({ data, previous }) {
1139
1182
  (data.crossSource?.length ?? 0) >= 3 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1140
1183
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Everything, on one time axis" }),
1141
1184
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "Each row keeps its own scale \u2014 traffic and revenue are not co-scaled, because whatever factor made them share an axis would invent a correlation the data never claimed. Aligned rows show the same co-movement and assert nothing about relative size." }),
1185
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "One line per row is the truest figure available. Hover a day, or use the control below, to see what each source saw of it." }),
1142
1186
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1143
1187
  CrossSourceTimeline,
1144
1188
  {
@@ -1146,23 +1190,21 @@ function MeasurementHealthPanel({ data, previous }) {
1146
1190
  markers: data.timelineEvents ?? [],
1147
1191
  series: [
1148
1192
  {
1149
- key: "vercel",
1193
+ // One traffic row, not two. The line is what happened; GA4's view of it
1194
+ // is the shaded area beneath. Two peer lines made the reader reconcile
1195
+ // before getting an answer, and GA4 is not a competing estimate of
1196
+ // pageviews — it is a lossy subset of them.
1197
+ key: "traffic",
1150
1198
  label: "Pageviews",
1151
1199
  source: "Vercel",
1152
1200
  complete: true,
1153
1201
  unit: "count",
1154
- points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.vercelPageviews }))
1155
- },
1156
- {
1157
- key: "ga4",
1158
- label: "Sessions",
1159
- source: "GA4",
1160
- complete: false,
1161
- unit: "count",
1162
- // The band runs from what GA4 measured up to where the capture model
1163
- // says it probably sits. The measured line is never replaced by it.
1164
- grossUpFactor: data.capture?.rate && data.capture.rate > 0 && data.capture.rate < 1 ? 1 / data.capture.rate : void 0,
1165
- points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.ga4Sessions }))
1202
+ points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.vercelPageviews })),
1203
+ shortfall: {
1204
+ label: "Seen by GA4",
1205
+ source: "GA4",
1206
+ points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.ga4Sessions }))
1207
+ }
1166
1208
  },
1167
1209
  ...data.crossSource?.some((d) => d.revenue !== null) ? [{
1168
1210
  key: "revenue",