@liiift-studio/sanity-visitor-insights 0.18.0 → 0.19.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, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-B_OrmcdP.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-B_OrmcdP.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-BUZuS1c-.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-BUZuS1c-.mjs';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
@@ -373,14 +373,6 @@ interface Series {
373
373
  source: Series['source'];
374
374
  points: SeriesPoint[];
375
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".
382
- */
383
- grossUpFactor?: number;
384
376
  }
385
377
  /** A dated event drawn through every row, e.g. a campaign send. */
386
378
  interface TimelineMarker {
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, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-B_OrmcdP.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-B_OrmcdP.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-BUZuS1c-.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-BUZuS1c-.js';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
@@ -373,14 +373,6 @@ interface Series {
373
373
  source: Series['source'];
374
374
  points: SeriesPoint[];
375
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".
382
- */
383
- grossUpFactor?: number;
384
376
  }
385
377
  /** A dated event drawn through every row, e.g. a campaign send. */
386
378
  interface TimelineMarker {
package/dist/index.js CHANGED
@@ -69,7 +69,7 @@ var import_sanity2 = require("sanity");
69
69
  var import_icons = require("@liiift-studio/sanity-ui-compat/icons");
70
70
 
71
71
  // src/studio/VisitorInsightsTool.tsx
72
- var import_react4 = require("react");
72
+ var import_react4 = __toESM(require("react"));
73
73
  var import_sanity_ui_compat4 = require("@liiift-studio/sanity-ui-compat");
74
74
 
75
75
  // src/studio/useReport.ts
@@ -205,23 +205,56 @@ function deltaStyle(tone) {
205
205
  }
206
206
  function MetricFigure({ metric, label, size = 4, unit = "count" }) {
207
207
  if (metric.status === "unavailable") {
208
- const reason = REASON_TEXT[metric.reason];
208
+ const reason = REASON_TEXT[metric.reason] ?? "No figure available";
209
209
  const detail = metric.detail ? `${reason}. ${metric.detail}` : reason;
210
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Tooltip, { content: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Box, { padding: 2, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, children: detail }) }), portal: true, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size, muted: true, "aria-label": `${label}: unavailable. ${detail}`, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: "\u2014" }) }) });
210
+ return (
211
+ // The reason travels as REAL TEXT in a visually-hidden span, not as an aria-label and not
212
+ // only in a tooltip.
213
+ //
214
+ // `aria-label` on a <Text> is silently dropped: it renders a bare <div>, whose role is
215
+ // `generic`, on which aria-label is prohibited by ARIA and ignored by every major
216
+ // browser. The em dash beside it is aria-hidden. So an unavailable metric announced as
217
+ // an EMPTY NODE — a blank cell — which is exactly the "absent must never look like a
218
+ // measured zero" invariant this whole type exists to enforce, inverted for anyone not
219
+ // looking at the screen. The tooltip was the only other route and it hangs off a
220
+ // non-focusable element, so it was pointer-only too.
221
+ //
222
+ // Delta already does this correctly; this is that pattern applied.
223
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Tooltip, { content: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Box, { padding: 2, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, children: detail }) }), portal: true, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size, muted: true, children: [
224
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: "\u2014" }),
225
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: visuallyHidden, children: [
226
+ label,
227
+ ": unavailable. ",
228
+ detail
229
+ ] })
230
+ ] }) })
231
+ );
211
232
  }
212
233
  const formatted = unit === "percent" ? `${metric.value.toFixed(1)}%` : formatCount(metric.value);
213
234
  if (metric.status === "partial") {
214
235
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
215
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size, "aria-label": `${label}: ${formatted}, partial. ${metric.note}`, children: formatted }),
236
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size, children: [
237
+ formatted,
238
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: visuallyHidden, children: [
239
+ ", partial. ",
240
+ metric.note
241
+ ] })
242
+ ] }),
216
243
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Badge, { tone: "caution", fontSize: 0, children: "Partial" })
217
244
  ] });
218
245
  }
219
246
  if (metric.status === "estimated") {
220
247
  const range = unit === "percent" ? `${metric.low.toFixed(1)}% to ${metric.high.toFixed(1)}%` : `${formatCount(metric.low)} to ${formatCount(metric.high)}`;
221
248
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
222
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size, "aria-label": `${label}: estimated ${formatted}, between ${range}. ${metric.basis}`, children: [
249
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size, children: [
223
250
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: "~" }),
224
- formatted
251
+ formatted,
252
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: visuallyHidden, children: [
253
+ "estimated, between ",
254
+ range,
255
+ ". ",
256
+ metric.basis
257
+ ] })
225
258
  ] }),
226
259
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: range }),
227
260
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Badge, { tone: "primary", fontSize: 0, children: "Estimated" })
@@ -231,7 +264,7 @@ function MetricFigure({ metric, label, size = 4, unit = "count" }) {
231
264
  const unhandled = metric;
232
265
  void unhandled;
233
266
  }
234
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size, "aria-label": `${label}: ${formatted}`, children: formatted });
267
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size, children: formatted });
235
268
  }
236
269
  function ComparisonBar({ label, metric, max, tone = "default" }) {
237
270
  const value = metric.status === "unavailable" ? null : metric.value;
@@ -256,7 +289,7 @@ function ComparisonBar({ label, metric, max, tone = "default" }) {
256
289
  ) : (
257
290
  // Track and fill are both Cards so the palette comes from the Studio theme tokens
258
291
  // rather than hand-picked CSS variables, and follows light/dark without extra work.
259
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { "aria-hidden": "true", radius: 2, tone: "transparent", border: true, style: { height: 8, overflow: "hidden" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { tone: tone === "default" ? "default" : tone, radius: 2, style: { width: `${width}%`, height: "100%" } }) })
292
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: barTrack, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...barFill, width: `${width}%` } }) })
260
293
  )
261
294
  ] });
262
295
  }
@@ -288,7 +321,7 @@ function ProportionChart({ bars, format, totalLabel }) {
288
321
  ] })
289
322
  ] })
290
323
  ] }),
291
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { "aria-hidden": "true", radius: 2, tone: "transparent", border: true, style: { height: 8, overflow: "hidden" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { tone: "primary", radius: 2, style: { width: `${Math.max(1, share * 100)}%`, height: "100%" } }) })
324
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: barTrack, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...barFill, width: `${Math.max(1, share * 100)}%` } }) })
292
325
  ] }, bar.key);
293
326
  }),
294
327
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
@@ -340,7 +373,7 @@ function FunnelChart({ stages, measurement }) {
340
373
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "medium", children: stage.label }),
341
374
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "semibold", children: formatCount(stage.value) })
342
375
  ] }),
343
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { "aria-hidden": "true", radius: 2, tone: "transparent", border: true, style: funnelTrack, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Card, { tone: "primary", radius: 2, style: { width: `${width}%`, height: "100%" } }) }),
376
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: { ...barTrack, height: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...barFill, width: `${width}%` } }) }),
344
377
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
345
378
  index === 0 ? "entry step" : `${formatPercent(share, 1)} of ${stages[0]?.label.toLowerCase()}`,
346
379
  index > 1 && stage.conversionFromPrevious !== null && previous ? ` \xB7 ${formatPercent(stage.conversionFromPrevious, 1)} of ${previous.label.toLowerCase()}` : ""
@@ -374,7 +407,21 @@ function funnelStage(active) {
374
407
  cursor: "default"
375
408
  };
376
409
  }
377
- var funnelTrack = { height: 10, overflow: "hidden" };
410
+ var barFill = {
411
+ height: "100%",
412
+ borderRadius: 2,
413
+ // currentColor at an alpha that clears 3:1 against the card in both themes, so the mark works
414
+ // from one declaration rather than needing a palette.
415
+ background: "currentColor",
416
+ opacity: 0.55
417
+ };
418
+ var barTrack = {
419
+ height: 8,
420
+ borderRadius: 2,
421
+ overflow: "hidden",
422
+ background: "currentColor",
423
+ opacity: 0.12
424
+ };
378
425
  var chartFrame = { position: "relative", width: "100%" };
379
426
  var readout = {
380
427
  position: "absolute",
@@ -748,21 +795,23 @@ function SortableTable({
748
795
  const content = column.render(row);
749
796
  return index === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { scope: "row", style: bodyCell, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: firstCell, children: [
750
797
  content,
751
- filterOn && // Per-row exclusion, because the fix for a contaminated
752
- // table is to take the bad row out and see what the
753
- // rest looks like. Rendered on every row rather than
754
- // on hover so it is reachable by keyboard and touch.
755
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
756
- "button",
757
- {
758
- type: "button",
759
- style: excludeButton,
760
- "aria-label": `Exclude ${key}`,
761
- title: `Exclude ${key} from this table`,
762
- onClick: () => setExcluded((current) => new Set(current).add(key)),
763
- children: "\xD7"
764
- }
765
- )
798
+ exportName || filterOn ? (
799
+ // Per-row exclusion, because the fix for a contaminated
800
+ // table is to take the bad row out and see what the
801
+ // rest looks like. Rendered on every row rather than
802
+ // on hover so it is reachable by keyboard and touch.
803
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
804
+ "button",
805
+ {
806
+ type: "button",
807
+ style: excludeButton,
808
+ "aria-label": `Exclude ${key}`,
809
+ title: `Exclude ${key} from this table`,
810
+ onClick: () => setExcluded((current) => new Set(current).add(key)),
811
+ children: "\xD7"
812
+ }
813
+ )
814
+ ) : null
766
815
  ] }) }, column.key) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { style: column.numeric ? bodyCellNumeric : bodyCell, children: content }, column.key);
767
816
  }) }, key);
768
817
  }),
@@ -898,11 +947,13 @@ var import_d3_scale = require("d3-scale");
898
947
  var import_d3_shape = require("d3-shape");
899
948
  var import_d3_array = require("d3-array");
900
949
  var import_jsx_runtime2 = require("react/jsx-runtime");
950
+ var WIDTH = 760;
901
951
  var ROW_HEIGHT = 74;
902
952
  var AXIS_HEIGHT = 26;
903
953
  var GUTTER = 52;
904
954
  var RIGHT_PAD = 12;
905
- var TOP_PAD = 16;
955
+ var TOP_PAD = 22;
956
+ var AXIS_TYPE = 11;
906
957
  function formatValue(value, unit, currency) {
907
958
  if (unit === "money") return formatMoney(value, currency ?? null);
908
959
  if (unit === "percent") return formatPercent(value, 1);
@@ -920,8 +971,7 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
920
971
  for (const s of series) for (const p of s.points) all.add(p.date);
921
972
  return [...all].sort();
922
973
  }, [series]);
923
- const width = 100;
924
- const plotWidth = width - GUTTER - RIGHT_PAD;
974
+ const plotWidth = WIDTH - GUTTER - RIGHT_PAD;
925
975
  const height = TOP_PAD + series.length * ROW_HEIGHT + AXIS_HEIGHT;
926
976
  const x = (0, import_react3.useMemo)(() => {
927
977
  if (dates.length === 0) return null;
@@ -932,7 +982,7 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
932
982
  const frame = frameRef.current;
933
983
  if (!frame || dates.length === 0) return;
934
984
  const box = frame.getBoundingClientRect();
935
- const ratio = (event.clientX - box.left) / box.width * width;
985
+ const ratio = (event.clientX - box.left) / box.width * WIDTH;
936
986
  const clamped = Math.max(GUTTER, Math.min(GUTTER + plotWidth, ratio));
937
987
  const index = Math.round((clamped - GUTTER) / plotWidth * (dates.length - 1));
938
988
  setHoverIndex(Math.max(0, Math.min(dates.length - 1, index)));
@@ -942,52 +992,94 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
942
992
  if (dates.length < 3 || series.length === 0 || !x) return null;
943
993
  const hoveredDate = hoverIndex !== null ? dates[hoverIndex] : null;
944
994
  const revealed = pinned || hoverIndex !== null;
995
+ const shapeOf = (row) => {
996
+ const real = row.points.filter((p) => p.value !== null);
997
+ if (real.length === 0) return `${row.label}: no data`;
998
+ const top = real.reduce((best, p) => p.value > best.value ? p : best, real[0]);
999
+ const total = real.reduce((sum, p) => sum + p.value, 0);
1000
+ return `${row.label} (${row.source}): ${formatValue(total, row.unit, currency)} in total, peaking at ${formatValue(top.value, row.unit, currency)} on ${tickLabel(/* @__PURE__ */ new Date(`${top.date}T00:00:00Z`))}`;
1001
+ };
1002
+ const missed = series.flatMap((row) => {
1003
+ if (!row.shortfall) return [];
1004
+ const seen = new Map(row.shortfall.points.map((p) => [p.date, p.value]));
1005
+ let gap = 0;
1006
+ let worst = null;
1007
+ for (const point of row.points) {
1008
+ if (point.value === null) continue;
1009
+ const saw = seen.get(point.date);
1010
+ if (saw == null) continue;
1011
+ gap += point.value - saw;
1012
+ const ratio = point.value > 0 ? 1 - saw / point.value : 0;
1013
+ if (!worst || ratio > worst.ratio) worst = { date: point.date, ratio };
1014
+ }
1015
+ if (gap <= 0 || !worst) return [];
1016
+ return [`${row.shortfall.source} missed ${formatCount(Math.round(gap))} ${row.label.toLowerCase()} over this period; the gap was widest on ${tickLabel(/* @__PURE__ */ new Date(`${worst.date}T00:00:00Z`))} at ${formatPercent(worst.ratio, 0)}.`];
1017
+ });
1018
+ const summary = `${dates.length} days from ${tickLabel(/* @__PURE__ */ new Date(`${dates[0]}T00:00:00Z`))} to ${tickLabel(/* @__PURE__ */ new Date(`${dates[dates.length - 1]}T00:00:00Z`))}. ` + series.map(shapeOf).join(". ") + ". " + missed.join(" ") + (markers.length > 0 ? ` ${markers.length} campaign send${markers.length === 1 ? "" : "s"} marked.` : "");
945
1019
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_sanity_ui_compat2.Stack, { space: 3, children: [
946
1020
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: frameStyle, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
947
1021
  "svg",
948
1022
  {
949
1023
  ref: frameRef,
950
- viewBox: `0 0 ${width} ${height}`,
951
- preserveAspectRatio: "none",
952
- style: { width: "100%", height: series.length * ROW_HEIGHT + AXIS_HEIGHT + TOP_PAD, display: "block" },
1024
+ viewBox: `0 0 ${WIDTH} ${height}`,
1025
+ style: { width: "100%", height: "auto", display: "block" },
953
1026
  onMouseMove: onMove,
954
1027
  onMouseLeave: () => setHoverIndex(null),
1028
+ tabIndex: 0,
955
1029
  role: "img",
956
- "aria-label": `${series.map((s) => s.label).join(", ")} over ${dates.length} days, on one shared time axis`,
1030
+ "aria-label": summary,
1031
+ onFocus: () => setHoverIndex((current) => current ?? dates.length - 1),
1032
+ onBlur: () => setHoverIndex(null),
1033
+ onKeyDown: (event) => {
1034
+ let next = null;
1035
+ const current = hoverIndex ?? dates.length - 1;
1036
+ if (event.key === "ArrowRight") next = Math.min(current + 1, dates.length - 1);
1037
+ if (event.key === "ArrowLeft") next = Math.max(current - 1, 0);
1038
+ if (event.key === "Home") next = 0;
1039
+ if (event.key === "End") next = dates.length - 1;
1040
+ if (event.key === "Escape") {
1041
+ setHoverIndex(null);
1042
+ return;
1043
+ }
1044
+ if (next === null) return;
1045
+ event.preventDefault();
1046
+ setHoverIndex(next);
1047
+ },
957
1048
  children: [
958
1049
  series.map((row, rowIndex) => {
959
1050
  const top = TOP_PAD + rowIndex * ROW_HEIGHT;
960
1051
  const bottom = top + ROW_HEIGHT - 18;
961
1052
  const values = row.points.map((p) => p.value).filter((v) => v !== null);
962
1053
  const peak = (0, import_d3_array.max)(values) ?? 0;
963
- const grossed = row.grossUpFactor && row.grossUpFactor > 1 ? peak * row.grossUpFactor : peak;
964
- const y = (0, import_d3_scale.scaleLinear)().domain([0, grossed || 1]).nice().range([bottom, top]);
1054
+ const y = (0, import_d3_scale.scaleLinear)().domain([0, peak || 1]).nice().range([bottom, top]);
965
1055
  const at = (p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`));
966
1056
  const defined = (p) => p.value !== null;
967
1057
  const lineGen = (0, import_d3_shape.line)().defined(defined).x(at).y((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
968
1058
  const shortfallPoints = row.shortfall?.points ?? [];
969
1059
  const byDate = new Map(shortfallPoints.map((p) => [p.date, p.value]));
970
1060
  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);
972
1061
  const path = lineGen(row.points) ?? "";
973
1062
  const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
974
- const band = row.grossUpFactor && row.grossUpFactor > 1 ? bandGen(row.points) ?? "" : "";
975
1063
  const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints.filter((p) => p.value !== null)) ?? "" : "";
976
1064
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
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 }),
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) }),
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 }),
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 }),
1065
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: bottom, y2: bottom, stroke: "currentColor", strokeWidth: 1, opacity: 0.45 }),
1066
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER, y: top - 6, fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.75, fontWeight: 500, children: row.label }),
1067
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("text", { x: GUTTER + plotWidth, y: top - 6, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.55, children: [
1068
+ row.source,
1069
+ row.complete ? "" : " \xB7 partial"
1070
+ ] }),
1071
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER - 6, y: y(y.domain()[1]) + 4, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.7, children: formatValue(y.domain()[1], row.unit, currency) }),
1072
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER - 6, y: bottom + 4, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.7, children: "0" }),
1073
+ gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: gap, fill: "currentColor", opacity: revealed ? 0.34 : 0.26 }),
1074
+ shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { d: shortfallLine, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeDasharray: "6 4", opacity: 0.8 }),
983
1075
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
984
1076
  "path",
985
1077
  {
986
1078
  d: path,
987
1079
  fill: "none",
988
1080
  stroke: "currentColor",
989
- strokeWidth: 0.5,
990
- strokeDasharray: row.complete ? void 0 : "1.5 1",
1081
+ strokeWidth: 2,
1082
+ strokeDasharray: row.complete ? void 0 : "6 4",
991
1083
  opacity: row.complete ? 0.95 : 0.7
992
1084
  }
993
1085
  )
@@ -1005,12 +1097,12 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1005
1097
  y1: TOP_PAD - 6,
1006
1098
  y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1007
1099
  stroke: "currentColor",
1008
- strokeWidth: 0.3,
1009
- strokeDasharray: "0.8 0.8",
1100
+ strokeWidth: 1.25,
1101
+ strokeDasharray: "4 3",
1010
1102
  opacity: 0.5
1011
1103
  }
1012
1104
  ),
1013
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("circle", { cx: at, cy: TOP_PAD - 8, r: 0.9, fill: "currentColor", opacity: 0.7 })
1105
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("circle", { cx: at, cy: TOP_PAD - 8, r: 3, fill: "currentColor", opacity: 0.7 })
1014
1106
  ] }, `${marker.date}-${marker.label}`);
1015
1107
  }),
1016
1108
  hoveredDate && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
@@ -1021,7 +1113,7 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1021
1113
  y1: TOP_PAD - 6,
1022
1114
  y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1023
1115
  stroke: "currentColor",
1024
- strokeWidth: 0.25,
1116
+ strokeWidth: 1,
1025
1117
  opacity: 0.8
1026
1118
  }
1027
1119
  ),
@@ -1034,7 +1126,7 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1034
1126
  x: x(/* @__PURE__ */ new Date(`${date}T00:00:00Z`)),
1035
1127
  y: height - 6,
1036
1128
  textAnchor: position === 0 ? "start" : position === 2 ? "end" : "middle",
1037
- fontSize: 3,
1129
+ fontSize: AXIS_TYPE,
1038
1130
  fill: "currentColor",
1039
1131
  opacity: 0.55,
1040
1132
  children: tickLabel(/* @__PURE__ */ new Date(`${date}T00:00:00Z`))
@@ -1090,8 +1182,13 @@ function CrossSourceTimeline({ series, markers = [], currency }) {
1090
1182
  row.source,
1091
1183
  ")"
1092
1184
  ] }, row.key)),
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." }),
1185
+ missed.map((sentence) => (
1186
+ // The region's argument, stated as a number. It was drawn to scale and described
1187
+ // in the abstract, so the two facts it exists to convey — how much was missed and
1188
+ // when it was worst — were available only by looking hard at a pale fill.
1189
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: sentence }, sentence)
1190
+ )),
1191
+ 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 \u2014 a quantity, not a margin of error." }),
1095
1192
  markers.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_sanity_ui_compat2.Text, { size: 0, muted: true, children: "Vertical rules mark campaign sends." })
1096
1193
  ] })
1097
1194
  ] });
@@ -1203,7 +1300,11 @@ function MeasurementHealthPanel({ data, previous }) {
1203
1300
  shortfall: {
1204
1301
  label: "Seen by GA4",
1205
1302
  source: "GA4",
1206
- points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.ga4Sessions }))
1303
+ // PAGEVIEWS, not sessions. The region encodes the difference between
1304
+ // these two as an area, so both sides must be the same unit — this
1305
+ // previously shaded sessions under a pageview line, overstating the
1306
+ // loss by the pageviews-per-session ratio.
1307
+ points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.ga4Pageviews }))
1207
1308
  }
1208
1309
  },
1209
1310
  ...data.crossSource?.some((d) => d.revenue !== null) ? [{
@@ -1519,10 +1620,11 @@ function JourneyPanel({ data }) {
1519
1620
  key: "path",
1520
1621
  label: "Page",
1521
1622
  sortValue: (page) => page.path,
1522
- // The foundry's own URLs were the one table that did not link, while
1523
- // referrer hosts did so clicking took you to somebody else's site
1524
- // and the pages you might want to open would not open.
1525
- render: (page) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, children: page.path })
1623
+ // The comment here used to describe this fix without applying it: it said
1624
+ // the foundry's own URLs were the one table that did not link while
1625
+ // referrer hosts did, and then rendered plain text. A comment asserting
1626
+ // something the code does not do is worse than no comment.
1627
+ render: (page) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("a", { href: page.path, target: "_blank", rel: "noopener noreferrer", style: sourceLink, children: page.path })
1526
1628
  },
1527
1629
  {
1528
1630
  key: "sessions",
@@ -1957,7 +2059,7 @@ function ReportPanel({
1957
2059
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 4, radius: 2, tone: state.disabled ? "transparent" : "critical", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 3, children: [
1958
2060
  state.disabled && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, weight: "medium", children: "Switched off" }),
1959
2061
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: state.disabled, children: state.message }),
1960
- !state.disabled && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Button, { text: "Try again", mode: "ghost", fontSize: 1, onClick: reload }) })
2062
+ !state.disabled && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", style: rangeButton(false), onClick: reload, children: "Try again" }) })
1961
2063
  ] }) }),
1962
2064
  state.status === "ready" && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
1963
2065
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SourceStatusRow, { sources: state.envelope.sources }),
@@ -1992,6 +2094,38 @@ function isoDaysAgo(days) {
1992
2094
  return date.toISOString().slice(0, 10);
1993
2095
  }
1994
2096
  var COMPARED_REPORTS = ["acquisition", "measurement-health"];
2097
+ var PanelBoundary = class extends import_react4.default.Component {
2098
+ constructor(props) {
2099
+ super(props);
2100
+ this.state = { error: null };
2101
+ }
2102
+ static getDerivedStateFromError(error) {
2103
+ return { error };
2104
+ }
2105
+ componentDidCatch(error) {
2106
+ console.error("Visitor insights: panel render failed:", error.message);
2107
+ }
2108
+ render() {
2109
+ if (!this.state.error) return this.props.children;
2110
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Card, { padding: 4, radius: 2, tone: "critical", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 3, children: [
2111
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, weight: "medium", children: "This panel could not be drawn" }),
2112
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, children: "The other panels are unaffected. This usually means the site\u2019s API route is a different version from this Studio \u2014 redeploying the site normally clears it." }),
2113
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 0, muted: true, children: this.state.error.message }),
2114
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
2115
+ "button",
2116
+ {
2117
+ type: "button",
2118
+ style: rangeButton(false),
2119
+ onClick: () => {
2120
+ this.setState({ error: null });
2121
+ this.props.onRetry();
2122
+ },
2123
+ children: "Try again"
2124
+ }
2125
+ ) })
2126
+ ] }) });
2127
+ }
2128
+ };
1995
2129
  function VisitorInsightsTool(props) {
1996
2130
  const apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? "";
1997
2131
  const siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? "";
@@ -2019,7 +2153,7 @@ function VisitorInsightsTool(props) {
2019
2153
  style: { position: "relative" },
2020
2154
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_sanity_ui_compat4.Stack, { space: 4, children: [
2021
2155
  active && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_sanity_ui_compat4.Text, { size: 1, muted: true, children: active.blurb }),
2022
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ReportPanel, { report: activePanel, apiBaseUrl, range, custom })
2156
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PanelBoundary, { onRetry: () => setActivePanel(activePanel), children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ReportPanel, { report: activePanel, apiBaseUrl, range, custom }) })
2023
2157
  ] })
2024
2158
  }
2025
2159
  )