@liiift-studio/sanity-visitor-insights 0.73.0 → 0.75.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-DhfuNtgW.mjs';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, 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-DhfuNtgW.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-DfjCK0XY.mjs';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, 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-DfjCK0XY.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -297,6 +297,23 @@ interface FunnelStage {
297
297
  partial?: {
298
298
  from: string;
299
299
  };
300
+ /**
301
+ * This rung split by audience segment, in a stable order across rungs.
302
+ *
303
+ * The bar's WIDTH is unchanged — still this rung's share of the entry step — and the split
304
+ * subdivides that width. So a segment collapsing reads as its portion shrinking down the column,
305
+ * against a total that stays honestly anchored to entry.
306
+ *
307
+ * This replaces a second chart. `SurvivalLines` drew the same segments as normalised shares on
308
+ * its own axis, above a funnel drawing the pooled counts: two pictures of one fact, and the
309
+ * share-only one could not print a count, which at rungs of 23 and 12 and 7 is the only fact
310
+ * that survives. Its denominator floor also silenced it at exactly those rungs.
311
+ */
312
+ segments?: ReadonlyArray<{
313
+ key: string;
314
+ label: string;
315
+ value: number;
316
+ }>;
300
317
  }
301
318
  /** Props for FunnelChart. */
302
319
  interface FunnelChartProps {
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-DhfuNtgW.js';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, 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-DhfuNtgW.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-DfjCK0XY.js';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, 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-DfjCK0XY.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -297,6 +297,23 @@ interface FunnelStage {
297
297
  partial?: {
298
298
  from: string;
299
299
  };
300
+ /**
301
+ * This rung split by audience segment, in a stable order across rungs.
302
+ *
303
+ * The bar's WIDTH is unchanged — still this rung's share of the entry step — and the split
304
+ * subdivides that width. So a segment collapsing reads as its portion shrinking down the column,
305
+ * against a total that stays honestly anchored to entry.
306
+ *
307
+ * This replaces a second chart. `SurvivalLines` drew the same segments as normalised shares on
308
+ * its own axis, above a funnel drawing the pooled counts: two pictures of one fact, and the
309
+ * share-only one could not print a count, which at rungs of 23 and 12 and 7 is the only fact
310
+ * that survives. Its denominator floor also silenced it at exactly those rungs.
311
+ */
312
+ segments?: ReadonlyArray<{
313
+ key: string;
314
+ label: string;
315
+ value: number;
316
+ }>;
300
317
  }
301
318
  /** Props for FunnelChart. */
302
319
  interface FunnelChartProps {
package/dist/index.js CHANGED
@@ -279,10 +279,6 @@ var SERIES = {
279
279
  /** Revenue, same source, different unit. */
280
280
  revenue: "#BF5D9B"
281
281
  };
282
- var REGION_ALPHA = 0.18;
283
- function seriesFill(key, alpha = REGION_ALPHA) {
284
- return withAlpha(SERIES[key], alpha);
285
- }
286
282
  function withAlpha(hex, alpha) {
287
283
  const r = parseInt(hex.slice(1, 3), 16);
288
284
  const g = parseInt(hex.slice(3, 5), 16);
@@ -304,21 +300,14 @@ var MARKS = {
304
300
  "chart.coverage": { key: "ga4Sessions", alpha: 1, role: "data", note: "Share one source saw of another" },
305
301
  "chart.stem": { key: "orders", alpha: 1, role: "data", note: "One day that had orders" },
306
302
  "chart.zeroTick": { key: "orders", alpha: 1, role: "data", note: "A day MEASURED at zero \u2014 the package's founding distinction" },
307
- "chart.regionEdge": { key: "ga4Pageviews", alpha: 1, role: "data", note: "The upper boundary of the shortfall region" },
308
- "chart.region": {
309
- key: "ga4Pageviews",
310
- alpha: 0.22,
311
- role: "fill",
312
- boundary: "chart.regionEdge",
313
- note: "What the lossier source missed. Light on purpose \u2014 the line it covers must stay readable"
314
- },
303
+ // Renamed from `chart.regionEdge`. It was the boundary of a filled region; the region is gone —
304
+ // its width was the absolute gap, which under a flat coverage rate is the traffic curve scaled
305
+ // down, so it widened on busy days with no change in the instrument. This is now simply the
306
+ // lossier source's own line, which is what the collapse detector and the spoken summary read.
307
+ "chart.lossy": { key: "ga4Pageviews", alpha: 1, role: "data", note: "The lossier source's own level, dashed beneath the complete one" },
315
308
  "chart.ghost": { key: "comparison", alpha: 1, role: "data", note: "The same window last period, behind everything" },
316
309
  "chart.grid": { key: "neutral", alpha: 0.18, role: "furniture", note: "Grid rules" },
317
310
  "bar.fill": { key: "vercel", alpha: 1, role: "data", note: "Every proportion, comparison and funnel bar" },
318
- // The hatch on a rung whose count covers fewer days than the rail it sits in. A `data` mark, not
319
- // furniture: it is the only thing on that rung saying the figure is not comparable, so it has to
320
- // be as visible as a bar would have been.
321
- "bar.partial": { key: "vercel", alpha: 1, role: "data", note: "A funnel rung measured over part of the window" },
322
311
  "bar.track": { key: "neutral", alpha: 0.12, role: "furniture", note: "The rail a bar sits in" },
323
312
  // Neither a source nor money. It was drawn in the REVENUE hue, so a magenta bar between funnel
324
313
  // rungs read as takings; GA4's hue was rejected for the opposite reason, since that hue means
@@ -329,9 +318,6 @@ var MARKS = {
329
318
  "estimate.dot": { key: "ga4Pageviews", alpha: 1, role: "data", note: "One independent estimate of how much GA4 sees" },
330
319
  "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" },
331
320
  "estimate.rule": { key: "neutral", alpha: 0.45, role: "furniture", note: "The 100% reference a capture rate is read against" },
332
- "shift.now": { key: "vercel", alpha: 1, role: "data", note: "A channel this period" },
333
- "shift.before": { key: "comparison", alpha: 1, role: "data", note: "The same channel last period \u2014 context, not a second answer" },
334
- "shift.link": { key: "comparison", alpha: 0.55, role: "fill", boundary: "shift.before", note: "The distance between the two, which is the finding" },
335
321
  // A segment per colour rather than a dash pattern per segment. Dash was the ONLY channel
336
322
  // separating these lines, and it is the one channel `preserveAspectRatio="none"` distorted —
337
323
  // so two segments could be drawn identically on a wide pane. The keys are reused from the
@@ -673,6 +659,23 @@ function formatDay(iso) {
673
659
  if (Number.isNaN(date.getTime())) return iso;
674
660
  return `${date.getUTCDate()} ${date.toLocaleString("en-GB", { month: "short", timeZone: "UTC" })}`;
675
661
  }
662
+ function segmentColour(index) {
663
+ const names = ["survival.line", "survival.line.2", "survival.line.3", "survival.line.4"];
664
+ return mark(names[index % names.length]);
665
+ }
666
+ function segmentKeys(stages) {
667
+ const seen = /* @__PURE__ */ new Map();
668
+ for (const stage of stages) {
669
+ for (const segment of stage.segments ?? []) {
670
+ if (segment.value > 0 && !seen.has(segment.key)) seen.set(segment.key, segment.label);
671
+ }
672
+ }
673
+ return [...seen].map(([key, label]) => ({ key, label }));
674
+ }
675
+ function segmentShare(value, whole) {
676
+ if (!(whole > 0) || !(value > 0)) return 0;
677
+ return Math.min(1, value / whole);
678
+ }
676
679
  function rateLine(stage, index, entry, share, entryLabel) {
677
680
  if (index === 0) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "100% \u2014 everyone who arrived" });
678
681
  if (stage.partial) {
@@ -714,6 +717,8 @@ function FunnelChart({ stages, measurement }) {
714
717
  const share = entry > 0 ? stage.value / entry : 0;
715
718
  const width = Math.max(1.5, Math.min(1, share) * 100);
716
719
  const active = activeKey === stage.key;
720
+ const hasSplit = (stage.segments?.length ?? 0) > 0;
721
+ const remainder = hasSplit ? Math.max(0, 1 - (stage.segments ?? []).reduce((sum, s) => sum + segmentShare(s.value, stage.value), 0)) : 0;
717
722
  const delta = previous ? previous.value - stage.value : 0;
718
723
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: funnelItem, children: [
719
724
  previous && gapLabel(delta, measurement, entry) !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: funnelGapStyle(measurement === "sequence" && delta > 0 && entry > 0), children: [
@@ -750,15 +755,34 @@ function FunnelChart({ stages, measurement }) {
750
755
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "medium", children: stage.label }),
751
756
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "semibold", children: formatCount(stage.value) })
752
757
  ] }),
753
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: { ...barTrack, height: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
758
+ stage.partial ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: partialSlot }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: { ...barTrack, height: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
754
759
  "div",
755
760
  {
756
- style: stage.partial ? {
757
- height: "100%",
758
- width: "100%",
759
- borderRadius: 3,
760
- backgroundImage: `repeating-linear-gradient(135deg, ${mark("bar.partial")} 0 4px, transparent 4px 8px)`
761
- } : { ...barFill, width: `${width}%` }
761
+ style: {
762
+ ...barFill,
763
+ width: `${width}%`,
764
+ display: "flex",
765
+ overflow: "hidden",
766
+ // Transparent when split. Left as the ordinary fill, the parent's own
767
+ // colour showed through wherever the segments did not reach — and on a
768
+ // rung where they sum to 451 of 475 that painted the missing 24 in the
769
+ // FIRST segment's colour, reading as a third segment of it.
770
+ ...hasSplit ? { background: "transparent" } : {}
771
+ },
772
+ children: [
773
+ (stage.segments ?? []).map((segment, segmentIndex) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
774
+ "div",
775
+ {
776
+ style: {
777
+ width: `${segmentShare(segment.value, stage.value) * 100}%`,
778
+ background: segmentColour(segmentIndex),
779
+ height: "100%"
780
+ }
781
+ },
782
+ segment.key
783
+ )),
784
+ hasSplit && remainder > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { width: `${remainder * 100}%`, background: mark("bar.track"), height: "100%" } })
785
+ ]
762
786
  }
763
787
  ) }),
764
788
  rateLine(stage, index, entry, share, stages[0]?.label ?? "")
@@ -767,10 +791,11 @@ function FunnelChart({ stages, measurement }) {
767
791
  )
768
792
  ] }, stage.key);
769
793
  }) }),
770
- stages.some((stage) => stage.partial) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: funnelKey, children: [
771
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", style: funnelKeySwatch }),
772
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "Striped: tracking started partway through this period, so the step covers fewer days than the ones above it and cannot be compared with them." })
773
- ] }),
794
+ segmentKeys(stages).length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: funnelSegmentKey, children: segmentKeys(stages).map((segment, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
795
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", style: { ...hoverSwatch, background: segmentColour(index), display: "inline-block", marginRight: 6 } }),
796
+ segment.label
797
+ ] }, segment.key)) }),
798
+ stages.some((stage) => stage.partial) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "Steps with no bar were only tracked for part of this period, so they cover fewer days than the ones above and cannot be drawn against them. Their counts are exact for the days they do cover." }),
774
799
  withheldAny && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
775
800
  "Shares are shown only for steps at least ",
776
801
  MIN_RATE_DENOMINATOR,
@@ -787,15 +812,8 @@ function gapLabel(delta, measurement, entry = 0) {
787
812
  }
788
813
  return `${formatCount(-delta)} more \u2014 not a subset of the step above`;
789
814
  }
790
- var funnelKey = { display: "flex", alignItems: "center", gap: 8, paddingTop: 4 };
791
- var funnelKeySwatch = {
792
- display: "inline-block",
793
- width: 24,
794
- height: 10,
795
- flex: "0 0 auto",
796
- borderRadius: 3,
797
- backgroundImage: `repeating-linear-gradient(135deg, ${mark("bar.partial")} 0 4px, transparent 4px 8px)`
798
- };
815
+ var partialSlot = { height: 10 };
816
+ var funnelSegmentKey = { display: "flex", gap: 14, flexWrap: "wrap", paddingTop: 4 };
799
817
  var funnelList = { listStyle: "none", margin: 0, padding: 0 };
800
818
  var funnelItem = { display: "grid", gap: 4 };
801
819
  function funnelGapStyle(hasBar) {
@@ -1013,177 +1031,14 @@ function EstimateDotPlot({
1013
1031
  " of ",
1014
1032
  formatCount(estimate.actual),
1015
1033
  wide && " \u2014 too small a sample to lean on",
1034
+ estimate.note && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: estimateNote, children: estimate.note }),
1016
1035
  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."
1017
1036
  ] })
1018
1037
  ] }, estimate.basis);
1019
1038
  }),
1020
- /* @__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%." })
1021
- ] });
1022
- }
1023
- function SurvivalLines({
1024
- segments,
1025
- stepLabels,
1026
- partialSteps = [],
1027
- minDenominator = MIN_RATE_DENOMINATOR
1028
- }) {
1029
- const [active, setActive] = import_react2.default.useState(null);
1030
- const [steppedByKey, setSteppedByKey] = import_react2.default.useState(false);
1031
- if (segments.length < 2 || stepLabels.length < 2) return null;
1032
- const width = 320;
1033
- const height = 132;
1034
- const x = (i) => i / (stepLabels.length - 1) * width;
1035
- const drawn = segments.map((segment, index) => {
1036
- const entry = segment.values[0];
1037
- if (entry === null || entry === void 0 || entry <= 0) return null;
1038
- const points = [];
1039
- const shares = [];
1040
- for (let i = 0; i < segment.values.length && i < stepLabels.length; i++) {
1041
- const value = segment.values[i];
1042
- const previous = i === 0 ? entry : segment.values[i - 1];
1043
- if (value === null || value === void 0) break;
1044
- if (i > 0 && (previous === null || previous === void 0 || previous < minDenominator)) break;
1045
- if (partialSteps.includes(i)) break;
1046
- const share = Math.min(1, value / entry);
1047
- points.push([x(i), height - share * height]);
1048
- shares.push(share);
1049
- }
1050
- if (points.length < 2) return null;
1051
- return { segment, index, points, shares, colour: segmentColour(index) };
1052
- });
1053
- const lines = drawn.filter((d) => d !== null);
1054
- if (lines.length === 0) return null;
1055
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 3, children: [
1056
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: survivalFrame, children: [
1057
- [1, 0.5, 0].map((level) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: survivalYLabel(level), children: level === 1 ? "100%" : level === 0.5 ? "50%" : "0%" }, level)),
1058
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1059
- "svg",
1060
- {
1061
- viewBox: `0 0 ${width} ${height}`,
1062
- preserveAspectRatio: "none",
1063
- style: { width: "100%", height: 150, overflow: "visible" },
1064
- role: "img",
1065
- "aria-label": survivalSummary(lines, stepLabels),
1066
- children: [
1067
- [1, 0.5, 0].map((level) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1068
- "line",
1069
- {
1070
- x1: 0,
1071
- x2: width,
1072
- y1: height * (1 - level),
1073
- y2: height * (1 - level),
1074
- stroke: mark("survival.grid"),
1075
- strokeWidth: 0.6,
1076
- vectorEffect: "non-scaling-stroke"
1077
- },
1078
- level
1079
- )),
1080
- stepLabels.map((label, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1081
- "rect",
1082
- {
1083
- x: i === 0 ? 0 : x(i) - width / (stepLabels.length - 1) / 2,
1084
- y: 0,
1085
- width: width / (stepLabels.length - 1) / (i === 0 || i === stepLabels.length - 1 ? 2 : 1),
1086
- height,
1087
- fill: "transparent",
1088
- onMouseEnter: () => {
1089
- setSteppedByKey(false);
1090
- setActive(i);
1091
- },
1092
- onMouseLeave: () => setActive(null)
1093
- },
1094
- label
1095
- )),
1096
- active !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1097
- "line",
1098
- {
1099
- x1: x(active),
1100
- x2: x(active),
1101
- y1: 0,
1102
- y2: height,
1103
- stroke: mark("survival.grid"),
1104
- strokeWidth: 1
1105
- }
1106
- ),
1107
- lines.map(({ segment, points, colour }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1108
- "polyline",
1109
- {
1110
- points: points.map(([px, py]) => `${px},${py}`).join(" "),
1111
- fill: "none",
1112
- stroke: colour,
1113
- strokeWidth: 1.6,
1114
- vectorEffect: "non-scaling-stroke",
1115
- strokeLinejoin: "round"
1116
- },
1117
- segment.key
1118
- )),
1119
- active !== null && lines.map(({ segment, points, colour }) => {
1120
- const point = points[active];
1121
- if (!point) return null;
1122
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1123
- "line",
1124
- {
1125
- x1: point[0],
1126
- x2: point[0],
1127
- y1: point[1],
1128
- y2: point[1],
1129
- stroke: colour,
1130
- strokeWidth: 6,
1131
- strokeLinecap: "round",
1132
- vectorEffect: "non-scaling-stroke"
1133
- },
1134
- segment.key
1135
- );
1136
- })
1137
- ]
1138
- }
1139
- )
1140
- ] }),
1141
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: survivalAxis, children: stepLabels.map((label, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1142
- "button",
1143
- {
1144
- type: "button",
1145
- style: survivalTick(i === active, i, stepLabels.length),
1146
- "aria-label": `Read ${label}`,
1147
- onMouseEnter: () => {
1148
- setSteppedByKey(false);
1149
- setActive(i);
1150
- },
1151
- onMouseLeave: () => setActive(null),
1152
- onFocus: () => {
1153
- setSteppedByKey(true);
1154
- setActive(i);
1155
- },
1156
- onBlur: () => setActive(null),
1157
- children: label
1158
- },
1159
- label
1160
- )) }),
1161
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: survivalLegend, "aria-live": steppedByKey ? "polite" : "off", children: lines.map(({ segment, shares, colour }) => {
1162
- const share = active === null ? null : shares[active];
1163
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, children: [
1164
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...hoverSwatch, background: colour, display: "inline-block", marginRight: 6 } }),
1165
- segment.label,
1166
- active !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("strong", { style: survivalReadoutValue, children: [
1167
- "\xA0",
1168
- share === null || share === void 0 ? "not measured here" : formatPercent(share, 0)
1169
- ] })
1170
- ] }, segment.key);
1171
- }) })
1039
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "The rule marks 100%." })
1172
1040
  ] });
1173
1041
  }
1174
- function segmentColour(index) {
1175
- const names = ["survival.line", "survival.line.2", "survival.line.3", "survival.line.4"];
1176
- return mark(names[index % names.length]);
1177
- }
1178
- function survivalSummary(lines, stepLabels) {
1179
- const parts = lines.map(({ segment, shares }) => {
1180
- const last = shares.length - 1;
1181
- const share = shares[last];
1182
- const reached = stepLabels[last] ?? "the last measured step";
1183
- return share === null || share === void 0 ? `${segment.label} could not be followed past ${reached}` : `${segment.label} ${formatPercent(share, 0)} by ${reached}`;
1184
- });
1185
- return `Share of each segment still present at each step, starting from ${stepLabels[0]}. ${parts.join(". ")}.`;
1186
- }
1187
1042
  function RatioFigure({
1188
1043
  value,
1189
1044
  format,
@@ -1435,6 +1290,7 @@ var estimateTrack = {
1435
1290
  borderRadius: 2,
1436
1291
  background: mark("bar.track")
1437
1292
  };
1293
+ var estimateNote = { display: "block", marginTop: 2, opacity: 0.85 };
1438
1294
  var estimateRule = {
1439
1295
  position: "absolute",
1440
1296
  top: 0,
@@ -1458,82 +1314,6 @@ var estimateDot = {
1458
1314
  borderRadius: "50%",
1459
1315
  background: mark("estimate.dot")
1460
1316
  };
1461
- var shiftTrack = {
1462
- position: "relative",
1463
- height: 16,
1464
- borderRadius: 2,
1465
- background: mark("bar.track")
1466
- };
1467
- var shiftLink = {
1468
- position: "absolute",
1469
- top: 7,
1470
- height: 2,
1471
- background: mark("shift.link")
1472
- };
1473
- var shiftBefore = {
1474
- position: "absolute",
1475
- top: 3,
1476
- width: 10,
1477
- height: 10,
1478
- marginLeft: -5,
1479
- borderRadius: "50%",
1480
- border: `2px solid ${mark("shift.before")}`,
1481
- background: "transparent"
1482
- };
1483
- var shiftNow = {
1484
- position: "absolute",
1485
- top: 3,
1486
- width: 10,
1487
- height: 10,
1488
- marginLeft: -5,
1489
- borderRadius: "50%",
1490
- background: mark("shift.now")
1491
- };
1492
- var survivalLegend = { display: "flex", gap: 14, flexWrap: "wrap" };
1493
- var survivalFrame = { paddingLeft: 34, position: "relative" };
1494
- var survivalAxis = {
1495
- position: "relative",
1496
- // Reserves the row's height, since the ticks inside it are positioned.
1497
- height: 30,
1498
- marginLeft: 34
1499
- };
1500
- function survivalTick(activeTick, index, count) {
1501
- const at = count > 1 ? index / (count - 1) * 100 : 50;
1502
- return {
1503
- position: "absolute",
1504
- left: `${at}%`,
1505
- // The ends align to the plot edge; everything between is centred on its vertex.
1506
- transform: index === 0 ? "none" : index === count - 1 ? "translateX(-100%)" : "translateX(-50%)",
1507
- appearance: "none",
1508
- background: "transparent",
1509
- border: "none",
1510
- borderTop: `2px solid ${activeTick ? "currentColor" : "transparent"}`,
1511
- color: "inherit",
1512
- opacity: activeTick ? 1 : 0.7,
1513
- font: "inherit",
1514
- fontSize: "0.72em",
1515
- fontWeight: activeTick ? 600 : 400,
1516
- padding: "6px 4px",
1517
- minHeight: 24,
1518
- cursor: "pointer",
1519
- whiteSpace: "nowrap"
1520
- };
1521
- }
1522
- function survivalYLabel(level) {
1523
- return {
1524
- position: "absolute",
1525
- left: 0,
1526
- // 150 is the plot's rendered height; the label is nudged up by half its own line so it sits
1527
- // on the rule rather than under it.
1528
- top: 150 * (1 - level) - 6,
1529
- width: 28,
1530
- textAlign: "right",
1531
- fontSize: 10,
1532
- opacity: 0.7,
1533
- fontVariantNumeric: "tabular-nums"
1534
- };
1535
- }
1536
- var survivalReadoutValue = { fontVariantNumeric: "tabular-nums" };
1537
1317
  var ratioRow = {
1538
1318
  display: "flex",
1539
1319
  alignItems: "baseline",
@@ -2008,8 +1788,6 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
2008
1788
  const defined = (p) => p.value !== null;
2009
1789
  const lineGen = (0, import_d3_shape.line)().defined(defined).x(at).y((p) => y(p.value)).curve(import_d3_shape.curveLinear);
2010
1790
  const shortfallPoints = row.shortfall?.points ?? [];
2011
- const byDate = new Map(shortfallPoints.map((p) => [p.date, p.value]));
2012
- 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.curveLinear);
2013
1791
  const path = row.mark === "events" ? "" : lineGen(row.points) ?? "";
2014
1792
  const ghost = row.comparison && row.comparison.length > 1 ? lineGen(row.comparison.slice(0, dates.length).map((point, i) => ({
2015
1793
  date: dates[i] ?? point.date,
@@ -2019,7 +1797,6 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
2019
1797
  const stemWidth = Math.max(1, Math.min(9, pitch > 2 ? pitch - 1 : pitch));
2020
1798
  const stems = row.mark === "events" ? row.points.filter((p) => p.value !== null && p.value !== 0) : [];
2021
1799
  const zeroes = row.mark === "events" && pitch >= 6 ? row.points.filter((p) => p.value === 0) : [];
2022
- const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
2023
1800
  const shortfallLine = row.shortfall ? lineGen(shortfallPoints) ?? "" : "";
2024
1801
  const clipId = `${instanceId}-row-${rowIndex}`;
2025
1802
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
@@ -2044,20 +1821,12 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
2044
1821
  strokeDasharray: "2 3"
2045
1822
  }
2046
1823
  ),
2047
- gap && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2048
- "path",
2049
- {
2050
- d: gap,
2051
- fill: shortfallStroke ? seriesFill(colorFor(row.shortfall.source, row.unit, row.shortfall.color), MARKS["chart.region"].alpha) : "currentColor",
2052
- opacity: shortfallStroke ? 1 : 0.48
2053
- }
2054
- ),
2055
1824
  shortfallLine && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2056
1825
  "path",
2057
1826
  {
2058
1827
  d: shortfallLine,
2059
1828
  fill: "none",
2060
- stroke: mark("chart.regionEdge"),
1829
+ stroke: mark("chart.lossy"),
2061
1830
  strokeWidth: 1.5,
2062
1831
  strokeDasharray: "6 4"
2063
1832
  }
@@ -2229,22 +1998,13 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
2229
1998
  ] }, m.label))
2230
1999
  ] }),
2231
2000
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: legendRow, children: [
2232
- series.some((row) => row.shortfall) && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
2233
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2234
- LegendKey,
2235
- {
2236
- swatch: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: legendDash(shortfallColour(series)) }),
2237
- label: `${shortfallLabel(series)} \u2014 the dashed line`
2238
- }
2239
- ),
2240
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2241
- LegendKey,
2242
- {
2243
- swatch: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: legendBlock(seriesFill(shortfallKey(series), MARKS["chart.region"].alpha), shortfallColour(series)) }),
2244
- label: "Shaded: what your analytics did not see"
2245
- }
2246
- )
2247
- ] }),
2001
+ series.some((row) => row.shortfall) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_jsx_runtime2.Fragment, { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2002
+ LegendKey,
2003
+ {
2004
+ swatch: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: legendDash(shortfallColour(series)) }),
2005
+ label: `${shortfallLabel(series)} \u2014 the dashed line`
2006
+ }
2007
+ ) }),
2248
2008
  series.some((row) => (row.comparison?.length ?? 0) > 1) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2249
2009
  LegendKey,
2250
2010
  {
@@ -2281,15 +2041,6 @@ function legendDash(colour) {
2281
2041
  borderTop: `2px dashed ${colour}`
2282
2042
  };
2283
2043
  }
2284
- function legendBlock(fill, edge) {
2285
- return {
2286
- display: "inline-block",
2287
- width: 18,
2288
- height: 10,
2289
- background: fill,
2290
- borderTop: `1.5px dashed ${edge}`
2291
- };
2292
- }
2293
2044
  var legendMarker = {
2294
2045
  display: "inline-block",
2295
2046
  position: "relative",
@@ -2383,9 +2134,6 @@ function metricOr(metric, detail) {
2383
2134
  return { status: "unavailable", reason: "route_outdated", detail };
2384
2135
  }
2385
2136
  var OLDER_ROUTE = "This site\u2019s analytics route is older than this figure \u2014 it will appear after the site is next deployed.";
2386
- function maxOf(metrics) {
2387
- return metrics.reduce((max, metric) => !metric || metric.status === "unavailable" ? max : Math.max(max, metric.value), 0);
2388
- }
2389
2137
  function finiteOrNull(value) {
2390
2138
  return typeof value === "number" && Number.isFinite(value) ? value : null;
2391
2139
  }
@@ -2847,7 +2595,6 @@ function Verdict({ data, previous }) {
2847
2595
  );
2848
2596
  }
2849
2597
  function DataHealthPanel({ data, diagnostics }) {
2850
- const pageviewMax = maxOf([data.ga4Pageviews, data.vercelPageviews]);
2851
2598
  const contained = isContainment(data.vercelPageviews, data.ga4Pageviews);
2852
2599
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
2853
2600
  /* @__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: 2, children: [
@@ -3227,33 +2974,25 @@ function JourneyPanel({ data }) {
3227
2974
  label: step.label,
3228
2975
  value: step.count.value,
3229
2976
  conversionFromPrevious: step.conversionFromPrevious,
2977
+ // The device split, subdividing this rung's bar rather than drawn as a second chart.
2978
+ // Only counts that are present and whole travel: a segment GA4 stopped reporting is
2979
+ // left out of the split rather than drawn as a shrunken share of one.
2980
+ ...segments.length > 0 ? {
2981
+ segments: segments.flatMap((segment) => {
2982
+ const cell = segment.steps.find((s) => s.key === step.key);
2983
+ const value = cell ? metricSortValue(cell.count) : null;
2984
+ return value === null ? [] : [{ key: segment.key, label: segment.label, value }];
2985
+ })
2986
+ } : {},
3230
2987
  ...step.count.status === "partial" && step.count.coveredFrom ? { partial: { from: formatDay(step.count.coveredFrom) } } : {}
3231
2988
  }]
3232
2989
  );
3233
- const wholeSpine = spine.filter((step) => {
3234
- const cell = allSteps.find((s) => s.key === step.key);
3235
- return cell?.count.status !== "partial";
3236
- });
3237
2990
  const tracked = data.measurement === "sequence";
3238
2991
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
3239
2992
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: tracked ? "transparent" : "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
3240
2993
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, weight: "medium", children: tracked ? "Tracked path" : "Not a tracked path" }),
3241
2994
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: tracked, children: data.approximationNote })
3242
2995
  ] }) }),
3243
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3244
- SurvivalLines,
3245
- {
3246
- segments: segments.map((segment) => ({
3247
- key: segment.key,
3248
- label: segment.label,
3249
- values: wholeSpine.map((step) => {
3250
- const cell = segment.steps.find((s) => s.key === step.key);
3251
- return cell ? metricSortValue(cell.count) : null;
3252
- })
3253
- })),
3254
- stepLabels: wholeSpine.map((step) => step.label)
3255
- }
3256
- ),
3257
2996
  segments.length >= 2 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3258
2997
  ChartData,
3259
2998
  {
@@ -3465,6 +3204,8 @@ function TypefaceInterestPanel({ data }) {
3465
3204
  },
3466
3205
  {
3467
3206
  key: "bought",
3207
+ // Never folded. Zero sales is the finding, not an empty column.
3208
+ alwaysShow: true,
3468
3209
  label: "Bought (orders)",
3469
3210
  numeric: true,
3470
3211
  sortValue: (row) => metricSortValue(row.bought),
@@ -3472,6 +3213,8 @@ function TypefaceInterestPanel({ data }) {
3472
3213
  },
3473
3214
  {
3474
3215
  key: "revenue",
3216
+ // Same reason as Bought: an exact zero from the order book is a measurement.
3217
+ alwaysShow: true,
3475
3218
  label: "Revenue (orders)",
3476
3219
  numeric: true,
3477
3220
  sortValue: (row) => metricSortValue(row.revenue),