@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 +29 -8
- package/dist/index.d.ts +29 -8
- package/dist/index.js +59 -17
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +62 -20
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/studio/CrossSourceTimeline.tsx +111 -20
- package/src/studio/panels.test.tsx +20 -6
- package/src/studio/panels.tsx +14 -14
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
360
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
360
|
-
*
|
|
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(
|
|
965
|
-
const
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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",
|