@liiift-studio/sanity-visitor-insights 0.29.0 → 0.30.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-D1A9JY7h.mjs';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-D1A9JY7h.mjs';
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-B3BWyG3q.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-B3BWyG3q.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -100,6 +100,62 @@ interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolP
100
100
  }
101
101
  declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
102
102
 
103
+ /**
104
+ * The tool's view, in the URL — so a finding can be sent to someone.
105
+ *
106
+ * Every piece of state here was component-local: tab, range, and the custom window. A Studio
107
+ * reload, a browser back, or an accidental navigation discarded the whole investigation, and
108
+ * "Acquisition, 24 August to 7 September" — the exact view in which someone notices the thing worth
109
+ * noticing — could not be handed to a colleague or returned to by the person who found it. There is
110
+ * a "Copy as CSV" in this tool precisely because sharing a finding is the real task; the view
111
+ * itself was the one thing that could not be shared.
112
+ *
113
+ * The HASH, not the path or the query. Sanity's own router owns the path, and writing to it would
114
+ * fight the Studio for control of navigation; the hash is unclaimed, survives a reload, is carried
115
+ * by a copied link, and is ignored by every server. The value is namespaced because other tools in
116
+ * the same Studio may want the same trick.
117
+ *
118
+ * Everything here is a pure string transformation, so it is testable without a DOM — which matters,
119
+ * because the alternative is a feature whose only proof is clicking around a deployed Studio.
120
+ */
121
+ /** The part of the tool's state worth putting in a link. */
122
+ interface ViewState {
123
+ /** Tab id, e.g. `overview`. */
124
+ tab?: string;
125
+ /** Range key, e.g. `week` or `custom`. */
126
+ range?: string;
127
+ /** Custom window start, ISO date. Only meaningful when `range` is `custom`. */
128
+ from?: string;
129
+ /** Custom window end, ISO date. */
130
+ to?: string;
131
+ }
132
+ /**
133
+ * Serialise a view to the hash fragment it should occupy.
134
+ *
135
+ * Returns an empty string for an empty view, so a default view leaves the URL clean rather than
136
+ * decorating it with state nobody set.
137
+ *
138
+ * @param view - the state to encode
139
+ */
140
+ declare function encodeView(view: ViewState): string;
141
+ /**
142
+ * Read a view out of a hash fragment.
143
+ *
144
+ * Every field is validated and an unrecognised one is dropped rather than defaulted, so a truncated
145
+ * or hand-edited link degrades to whatever it could parse instead of failing or, worse, silently
146
+ * requesting a window nobody chose.
147
+ *
148
+ * @param hash - `window.location.hash`, with or without its leading `#`
149
+ */
150
+ declare function decodeView(hash: string): ViewState;
151
+ /**
152
+ * Replace our fragment in a hash, preserving anyone else's.
153
+ *
154
+ * @param hash - the current hash
155
+ * @param view - the state to write
156
+ */
157
+ declare function mergeIntoHash(hash: string, view: ViewState): string;
158
+
103
159
  /**
104
160
  * Shared renderers for metric values and comparison bars.
105
161
  *
@@ -426,6 +482,15 @@ interface Series {
426
482
  * event, on a date, of a size. Days with nothing draw nothing, which is the truth.
427
483
  */
428
484
  mark?: 'line' | 'events';
485
+ /**
486
+ * A fixed y domain, instead of scaling the row to its own peak.
487
+ *
488
+ * Small multiples normally scale each row to itself, which is right for quantities whose
489
+ * absolute size is not comparable between rows. It is wrong for a proportion: a coverage row
490
+ * auto-scaled to its own maximum would redraw 100% at whatever the best day happened to be, so
491
+ * a site running at a flat 20% would show a full-height line and read as healthy.
492
+ */
493
+ domain?: [number, number];
429
494
  points: SeriesPoint[];
430
495
  /**
431
496
  * What a lossier source saw of the same thing.
@@ -465,12 +530,6 @@ interface CrossSourceTimelineProps {
465
530
  */
466
531
  onBrush?: (start: string, end: string) => void;
467
532
  }
468
- /**
469
- * The cross-source timeline.
470
- *
471
- * Renders nothing rather than an empty frame when there is not enough to plot — two points cannot
472
- * show a shape, and an axis with one dot on it invites a reading it cannot support.
473
- */
474
533
  declare function CrossSourceTimeline({ series, markers, currency, onBrush }: CrossSourceTimelineProps): React.ReactElement | null;
475
534
 
476
535
  /**
@@ -579,4 +638,4 @@ interface VisitorInsightsPluginOptions {
579
638
  */
580
639
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
581
640
 
582
- export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, visitorInsights as default, formatCount, formatMoney, formatPercent, knownShortfall, useReport, visitorInsights };
641
+ export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type ViewState, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, decodeView, visitorInsights as default, encodeView, formatCount, formatMoney, formatPercent, knownShortfall, mergeIntoHash, useReport, visitorInsights };
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-D1A9JY7h.js';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-D1A9JY7h.js';
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-B3BWyG3q.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-B3BWyG3q.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -100,6 +100,62 @@ interface VisitorInsightsToolComponentProps extends Partial<VisitorInsightsToolP
100
100
  }
101
101
  declare function VisitorInsightsTool(props: VisitorInsightsToolComponentProps): React.ReactElement;
102
102
 
103
+ /**
104
+ * The tool's view, in the URL — so a finding can be sent to someone.
105
+ *
106
+ * Every piece of state here was component-local: tab, range, and the custom window. A Studio
107
+ * reload, a browser back, or an accidental navigation discarded the whole investigation, and
108
+ * "Acquisition, 24 August to 7 September" — the exact view in which someone notices the thing worth
109
+ * noticing — could not be handed to a colleague or returned to by the person who found it. There is
110
+ * a "Copy as CSV" in this tool precisely because sharing a finding is the real task; the view
111
+ * itself was the one thing that could not be shared.
112
+ *
113
+ * The HASH, not the path or the query. Sanity's own router owns the path, and writing to it would
114
+ * fight the Studio for control of navigation; the hash is unclaimed, survives a reload, is carried
115
+ * by a copied link, and is ignored by every server. The value is namespaced because other tools in
116
+ * the same Studio may want the same trick.
117
+ *
118
+ * Everything here is a pure string transformation, so it is testable without a DOM — which matters,
119
+ * because the alternative is a feature whose only proof is clicking around a deployed Studio.
120
+ */
121
+ /** The part of the tool's state worth putting in a link. */
122
+ interface ViewState {
123
+ /** Tab id, e.g. `overview`. */
124
+ tab?: string;
125
+ /** Range key, e.g. `week` or `custom`. */
126
+ range?: string;
127
+ /** Custom window start, ISO date. Only meaningful when `range` is `custom`. */
128
+ from?: string;
129
+ /** Custom window end, ISO date. */
130
+ to?: string;
131
+ }
132
+ /**
133
+ * Serialise a view to the hash fragment it should occupy.
134
+ *
135
+ * Returns an empty string for an empty view, so a default view leaves the URL clean rather than
136
+ * decorating it with state nobody set.
137
+ *
138
+ * @param view - the state to encode
139
+ */
140
+ declare function encodeView(view: ViewState): string;
141
+ /**
142
+ * Read a view out of a hash fragment.
143
+ *
144
+ * Every field is validated and an unrecognised one is dropped rather than defaulted, so a truncated
145
+ * or hand-edited link degrades to whatever it could parse instead of failing or, worse, silently
146
+ * requesting a window nobody chose.
147
+ *
148
+ * @param hash - `window.location.hash`, with or without its leading `#`
149
+ */
150
+ declare function decodeView(hash: string): ViewState;
151
+ /**
152
+ * Replace our fragment in a hash, preserving anyone else's.
153
+ *
154
+ * @param hash - the current hash
155
+ * @param view - the state to write
156
+ */
157
+ declare function mergeIntoHash(hash: string, view: ViewState): string;
158
+
103
159
  /**
104
160
  * Shared renderers for metric values and comparison bars.
105
161
  *
@@ -426,6 +482,15 @@ interface Series {
426
482
  * event, on a date, of a size. Days with nothing draw nothing, which is the truth.
427
483
  */
428
484
  mark?: 'line' | 'events';
485
+ /**
486
+ * A fixed y domain, instead of scaling the row to its own peak.
487
+ *
488
+ * Small multiples normally scale each row to itself, which is right for quantities whose
489
+ * absolute size is not comparable between rows. It is wrong for a proportion: a coverage row
490
+ * auto-scaled to its own maximum would redraw 100% at whatever the best day happened to be, so
491
+ * a site running at a flat 20% would show a full-height line and read as healthy.
492
+ */
493
+ domain?: [number, number];
429
494
  points: SeriesPoint[];
430
495
  /**
431
496
  * What a lossier source saw of the same thing.
@@ -465,12 +530,6 @@ interface CrossSourceTimelineProps {
465
530
  */
466
531
  onBrush?: (start: string, end: string) => void;
467
532
  }
468
- /**
469
- * The cross-source timeline.
470
- *
471
- * Renders nothing rather than an empty frame when there is not enough to plot — two points cannot
472
- * show a shape, and an axis with one dot on it invites a reading it cannot support.
473
- */
474
533
  declare function CrossSourceTimeline({ series, markers, currency, onBrush }: CrossSourceTimelineProps): React.ReactElement | null;
475
534
 
476
535
  /**
@@ -579,4 +638,4 @@ interface VisitorInsightsPluginOptions {
579
638
  */
580
639
  declare const visitorInsights: sanity.Plugin<VisitorInsightsPluginOptions>;
581
640
 
582
- export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, visitorInsights as default, formatCount, formatMoney, formatPercent, knownShortfall, useReport, visitorInsights };
641
+ export { AcquisitionData, AcquisitionPanel, ChartData, type ChartDataProps, ComparisonBar, CrossSourceTimeline, type CrossSourceTimelineProps, DataHealthPanel, Delta, type DeltaProps, DiagnosticReport, DiagnosticsPanel, FunnelChart, JourneyData, JourneyPanel, MeasurementHealthData, DataHealthPanel as MeasurementHealthPanel, MetricFigure, MetricValue, NoticeList, OverviewPanel, type ProportionBar, ProportionChart, type ProportionChartProps, RangeKey, ReportEnvelope, ReportName, type ReportState, type Series, type SeriesPoint, type SortColumn, SortableTable, type SortableTableProps, type TimelineMarker, TrendChart, TypefaceInterestData, TypefaceInterestPanel, type UseReportOptions, type ViewState, type VisitorInsightsPluginOptions, VisitorInsightsTool, type VisitorInsightsToolProps, decodeView, visitorInsights as default, encodeView, formatCount, formatMoney, formatPercent, knownShortfall, mergeIntoHash, useReport, visitorInsights };
package/dist/index.js CHANGED
@@ -51,13 +51,16 @@ __export(index_exports, {
51
51
  TypefaceInterestPanel: () => TypefaceInterestPanel,
52
52
  VisitorInsightsTool: () => VisitorInsightsTool,
53
53
  coverageForRange: () => coverageForRange,
54
+ decodeView: () => decodeView,
54
55
  default: () => index_default,
56
+ encodeView: () => encodeView,
55
57
  estimated: () => estimated,
56
58
  formatCount: () => formatCount,
57
59
  formatMoney: () => formatMoney,
58
60
  formatPercent: () => formatPercent,
59
61
  isReportName: () => isReportName,
60
62
  knownShortfall: () => knownShortfall,
63
+ mergeIntoHash: () => mergeIntoHash,
61
64
  ok: () => ok,
62
65
  partial: () => partial,
63
66
  previousRange: () => previousRange,
@@ -167,6 +170,50 @@ function useReport({ apiBaseUrl, report, range, custom, enabled = true }) {
167
170
  return { state, reload };
168
171
  }
169
172
 
173
+ // src/studio/urlState.ts
174
+ var KEY = "insights";
175
+ var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
176
+ var IDENTIFIER = /^[a-z][a-z0-9-]{0,31}$/;
177
+ function encodeView(view) {
178
+ const parts = [];
179
+ if (view.tab && IDENTIFIER.test(view.tab)) parts.push(`tab:${view.tab}`);
180
+ if (view.range && IDENTIFIER.test(view.range)) parts.push(`range:${view.range}`);
181
+ if (view.range === "custom") {
182
+ if (view.from && ISO_DATE.test(view.from)) parts.push(`from:${view.from}`);
183
+ if (view.to && ISO_DATE.test(view.to)) parts.push(`to:${view.to}`);
184
+ }
185
+ return parts.length > 0 ? `${KEY}=${parts.join(";")}` : "";
186
+ }
187
+ function decodeView(hash) {
188
+ const raw = hash.startsWith("#") ? hash.slice(1) : hash;
189
+ const mine = raw.split("&").find((part) => part.startsWith(`${KEY}=`));
190
+ if (!mine) return {};
191
+ const view = {};
192
+ for (const pair of mine.slice(KEY.length + 1).split(";")) {
193
+ const separator = pair.indexOf(":");
194
+ if (separator < 1) continue;
195
+ const name = pair.slice(0, separator);
196
+ const value = pair.slice(separator + 1);
197
+ if (name === "tab" && IDENTIFIER.test(value)) view.tab = value;
198
+ else if (name === "range" && IDENTIFIER.test(value)) view.range = value;
199
+ else if (name === "from" && ISO_DATE.test(value)) view.from = value;
200
+ else if (name === "to" && ISO_DATE.test(value)) view.to = value;
201
+ }
202
+ if (view.range === "custom" && !(view.from && view.to)) {
203
+ delete view.from;
204
+ delete view.to;
205
+ delete view.range;
206
+ }
207
+ return view;
208
+ }
209
+ function mergeIntoHash(hash, view) {
210
+ const raw = hash.startsWith("#") ? hash.slice(1) : hash;
211
+ const others = raw.split("&").filter((part) => part.length > 0 && !part.startsWith(`${KEY}=`));
212
+ const mine = encodeView(view);
213
+ const all = mine ? [...others, mine] : others;
214
+ return all.length > 0 ? `#${all.join("&")}` : "";
215
+ }
216
+
170
217
  // src/core/ranges.ts
171
218
  function formatInTimeZone(date, timeZone) {
172
219
  const parts = new Intl.DateTimeFormat("en-CA", {
@@ -1092,44 +1139,57 @@ function findCoverageIncident(days, dates) {
1092
1139
  const measured = days.filter((d) => d !== null);
1093
1140
  if (measured.length < 21) return null;
1094
1141
  const sorted = [...measured].sort((a, b) => a - b);
1095
- const normal = sorted[Math.floor(sorted.length / 2)];
1142
+ const normal = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))];
1096
1143
  if (normal <= 0.1) return null;
1097
1144
  const threshold = normal * 0.5;
1098
1145
  const MIN_RUN = 3;
1099
- let best = null;
1146
+ const MAX_GAP = 2;
1147
+ const runs = [];
1100
1148
  let runStart = null;
1149
+ let gap = 0;
1101
1150
  for (let i = 0; i <= days.length; i++) {
1102
- const value = days[i];
1103
- const low = i < days.length && value !== null && value !== void 0 && value < threshold;
1104
- if (low && runStart === null) runStart = i;
1105
- if (!low && runStart !== null) {
1106
- const length = i - runStart;
1107
- if (length >= MIN_RUN && (!best || length > best.end - best.start)) best = { start: runStart, end: i };
1151
+ const value = i < days.length ? days[i] : void 0;
1152
+ const low = value !== null && value !== void 0 && value < threshold;
1153
+ const unmeasured = i < days.length && (value === null || value === void 0);
1154
+ if (low) {
1155
+ if (runStart === null) runStart = i;
1156
+ gap = 0;
1157
+ } else if (unmeasured && runStart !== null && gap < MAX_GAP) {
1158
+ gap += 1;
1159
+ } else if (runStart !== null) {
1160
+ runs.push({ start: runStart, end: i - gap });
1108
1161
  runStart = null;
1162
+ gap = 0;
1109
1163
  }
1110
1164
  }
1111
- if (!best) return null;
1112
- const during = days.slice(best.start, best.end).filter((d) => d !== null);
1113
- const before = days.slice(0, best.start).filter((d) => d !== null);
1114
- if (before.length < 3 || during.length === 0) return null;
1165
+ runs.sort((a, b) => b.end - b.start - (a.end - a.start));
1115
1166
  const mean = (values) => values.reduce((sum, v) => sum + v, 0) / values.length;
1116
- const onset = dates[best.start];
1117
- if (!onset) return null;
1118
- return {
1119
- onset,
1120
- days: best.end - best.start,
1121
- before: mean(before),
1122
- during: mean(during),
1123
- // The run reaching the end of the series is the difference between "this happened" and "this
1124
- // is happening", which is the difference between a note and a job for today.
1125
- ongoing: best.end >= days.length
1126
- };
1167
+ for (const run of runs) {
1168
+ if (run.end - run.start < MIN_RUN) continue;
1169
+ const during = days.slice(run.start, run.end).filter((d) => d !== null);
1170
+ const before = days.slice(0, run.start).filter((d) => d !== null);
1171
+ if (before.length < 3 || during.length === 0) continue;
1172
+ const onset = dates[run.start];
1173
+ if (!onset) continue;
1174
+ const beforeMean = mean(before);
1175
+ const after = days.slice(run.end).filter((d) => d !== null);
1176
+ const recovered = after.length >= MIN_RUN && mean(after) >= beforeMean * 0.8;
1177
+ return {
1178
+ onset,
1179
+ days: run.end - run.start,
1180
+ before: beforeMean,
1181
+ during: mean(during),
1182
+ ongoing: !recovered
1183
+ };
1184
+ }
1185
+ return null;
1127
1186
  }
1128
1187
  function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1129
1188
  const [hoverIndex, setHoverIndex] = (0, import_react3.useState)(null);
1130
1189
  const [pinned, setPinned] = (0, import_react3.useState)(false);
1131
1190
  const [brushAnchor, setBrushAnchor] = (0, import_react3.useState)(null);
1132
1191
  const [brushed, setBrushed] = (0, import_react3.useState)(null);
1192
+ const instanceId = import_react3.default.useId().replace(/[^a-zA-Z0-9]/g, "");
1133
1193
  const [steppedByKeyboard, setSteppedByKeyboard] = (0, import_react3.useState)(false);
1134
1194
  const [tooShort, setTooShort] = (0, import_react3.useState)(false);
1135
1195
  const [measured, setMeasured] = (0, import_react3.useState)(WIDTH);
@@ -1337,13 +1397,34 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1337
1397
  }
1338
1398
  },
1339
1399
  children: [
1400
+ tickIndexes.map((index) => {
1401
+ const date = dates[index];
1402
+ if (!date) return null;
1403
+ const at = x(/* @__PURE__ */ new Date(`${date}T00:00:00Z`));
1404
+ if (!Number.isFinite(at)) return null;
1405
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1406
+ "line",
1407
+ {
1408
+ x1: at,
1409
+ x2: at,
1410
+ y1: TOP_PAD,
1411
+ y2: TOP_PAD + series.length * ROW_HEIGHT - 18,
1412
+ stroke: "currentColor",
1413
+ strokeWidth: 1,
1414
+ opacity: 0.08
1415
+ },
1416
+ `grid-${date}`
1417
+ );
1418
+ }),
1340
1419
  series.map((row, rowIndex) => {
1341
1420
  const top = TOP_PAD + rowIndex * ROW_HEIGHT;
1342
1421
  const bottom = top + ROW_HEIGHT - 18;
1343
1422
  const plotTop = top + LABEL_STRIP;
1344
1423
  const values = row.points.map((p) => p.value).filter((v) => v !== null);
1345
1424
  const peak = (0, import_d3_array.max)(values) ?? 0;
1346
- const y = (0, import_d3_scale.scaleLinear)().domain([0, peak || 1]).nice().range([bottom, plotTop]);
1425
+ const lowest = Math.min(0, ...values);
1426
+ const y = row.domain ? (0, import_d3_scale.scaleLinear)().domain(row.domain).range([bottom, plotTop]) : (0, import_d3_scale.scaleLinear)().domain([lowest, peak || 1]).nice().range([bottom, plotTop]);
1427
+ const zeroY = y(0);
1347
1428
  const at = (p) => x(/* @__PURE__ */ new Date(`${p.date}T00:00:00Z`));
1348
1429
  const defined = (p) => p.value !== null;
1349
1430
  const lineGen = (0, import_d3_shape.line)().defined(defined).x(at).y((p) => y(p.value)).curve(import_d3_shape.curveMonotoneX);
@@ -1357,10 +1438,10 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1357
1438
  const zeroes = row.mark === "events" && pitch >= 6 ? row.points.filter((p) => p.value === 0) : [];
1358
1439
  const gap = row.shortfall ? gapGen(row.points) ?? "" : "";
1359
1440
  const shortfallLine = row.shortfall && revealed ? lineGen(shortfallPoints) ?? "" : "";
1360
- const clipId = `row-clip-${rowIndex}`;
1441
+ const clipId = `${instanceId}-row-${rowIndex}`;
1361
1442
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("g", { children: [
1362
1443
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("clipPath", { id: clipId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("rect", { x: GUTTER, y: plotTop, width: plotWidth, height: Math.max(1, bottom - plotTop) }) }) }),
1363
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: bottom, y2: bottom, stroke: "currentColor", strokeWidth: 1, opacity: 0.45 }),
1444
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("line", { x1: GUTTER, x2: GUTTER + plotWidth, y1: zeroY, y2: zeroY, stroke: "currentColor", strokeWidth: 1, opacity: 0.45 }),
1364
1445
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("text", { x: GUTTER + 4, y: top + 11, fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.85, fontWeight: 500, children: row.label }),
1365
1446
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("text", { x: GUTTER + plotWidth, y: top + 11, textAnchor: "end", fontSize: AXIS_TYPE, fill: "currentColor", opacity: 0.72, children: [
1366
1447
  row.source,
@@ -1378,7 +1459,7 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1378
1459
  "rect",
1379
1460
  {
1380
1461
  x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1381
- y: bottom - 3,
1462
+ y: zeroY - 3,
1382
1463
  width: stemWidth,
1383
1464
  height: 3,
1384
1465
  fill: "currentColor",
@@ -1395,9 +1476,9 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1395
1476
  "rect",
1396
1477
  {
1397
1478
  x: Math.max(GUTTER, Math.min(GUTTER + plotWidth - stemWidth, cx - stemWidth / 2)),
1398
- y: Math.min(headY, bottom),
1479
+ y: Math.min(headY, zeroY),
1399
1480
  width: stemWidth,
1400
- height: Math.max(1.5, Math.abs(bottom - headY)),
1481
+ height: Math.max(1.5, Math.abs(zeroY - headY)),
1401
1482
  fill: "currentColor",
1402
1483
  opacity: 0.75
1403
1484
  },
@@ -1474,25 +1555,6 @@ function CrossSourceTimeline({ series, markers = [], currency, onBrush }) {
1474
1555
  opacity: 0.8
1475
1556
  }
1476
1557
  ),
1477
- tickIndexes.map((index) => {
1478
- const date = dates[index];
1479
- if (!date) return null;
1480
- const at = x(/* @__PURE__ */ new Date(`${date}T00:00:00Z`));
1481
- if (!Number.isFinite(at)) return null;
1482
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1483
- "line",
1484
- {
1485
- x1: at,
1486
- x2: at,
1487
- y1: TOP_PAD,
1488
- y2: height - AXIS_HEIGHT,
1489
- stroke: "currentColor",
1490
- strokeWidth: 1,
1491
- opacity: 0.08
1492
- },
1493
- `grid-${date}`
1494
- );
1495
- }),
1496
1558
  tickIndexes.map((index, position) => {
1497
1559
  const date = dates[index];
1498
1560
  if (!date) return null;
@@ -1673,7 +1735,7 @@ function OverviewPanel({ data, previous, onBrush }) {
1673
1735
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: metricOr(data.vercelPageviews, OLDER_ROUTE), label: "Pageviews" }),
1674
1736
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Delta, { current: metricSortValue(data.vercelPageviews), previous: metricSortValue(previous?.vercelPageviews) })
1675
1737
  ] }),
1676
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Pageviews, counted server-side." })
1738
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Pageviews, from Vercel\u2019s own counter." })
1677
1739
  ] }) }),
1678
1740
  /* @__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: 3, children: [
1679
1741
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Mailing list, total" }),
@@ -1723,6 +1785,37 @@ function OverviewPanel({ data, previous, onBrush }) {
1723
1785
  points: (data.crossSource ?? []).map((d) => ({ date: d.date, value: d.ga4Pageviews }))
1724
1786
  }
1725
1787
  },
1788
+ /*
1789
+ * Coverage as its own row, on a fixed 0–100% axis.
1790
+ *
1791
+ * The shaded band under the traffic line is an ABSOLUTE quantity — Vercel
1792
+ * minus GA4 — so under a flat 20% coverage it is 0.8 times the traffic curve
1793
+ * and tracks it exactly. Every busy day therefore shows a wider alarm than
1794
+ * every quiet day with no change whatever in the instrument, and the one
1795
+ * distinction this tool exists to draw — a standing shortfall against a dated
1796
+ * collapse — is the one the encoding could not show. It had to be asserted in
1797
+ * prose by a detector instead, which made that sentence the reader's only
1798
+ * witness to its own subject.
1799
+ *
1800
+ * As a proportion it draws itself: a standing shortfall is a flat line, a
1801
+ * collapse is a step with a date under it, a gradual decay is a ramp that
1802
+ * plainly is not an event, and a partial recovery is a line that visibly did
1803
+ * not return. The reader can check the sentence rather than trust it.
1804
+ */
1805
+ ...(data.crossSource ?? []).some((d) => d.vercelPageviews !== null && d.ga4Pageviews !== null) ? [{
1806
+ key: "coverage",
1807
+ label: "GA4 coverage",
1808
+ source: "GA4",
1809
+ complete: true,
1810
+ unit: "percent",
1811
+ // Fixed, not scaled to its own best day — otherwise a site at a flat 20%
1812
+ // draws a full-height line and reads as healthy.
1813
+ domain: [0, 1],
1814
+ points: (data.crossSource ?? []).map((d) => ({
1815
+ date: d.date,
1816
+ value: d.vercelPageviews !== null && d.ga4Pageviews !== null && d.vercelPageviews > 0 ? Math.min(1, d.ga4Pageviews / d.vercelPageviews) : null
1817
+ }))
1818
+ }] : [],
1726
1819
  ...data.crossSource?.some((d) => d.revenue !== null) ? [{
1727
1820
  key: "revenue",
1728
1821
  label: "Revenue",
@@ -1887,8 +1980,9 @@ function Verdict({ data, previous }) {
1887
1980
  return;
1888
1981
  }
1889
1982
  const change = (a - b) / Math.abs(b);
1890
- if (Math.abs(change) < 0.05) parts.push(`${label} flat`);
1891
- else parts.push(`${label} ${change > 0 ? "up" : "down"} ${formatPercent(Math.abs(change), 0)}`);
1983
+ if (Math.abs(change) < 0.05) {
1984
+ parts.push(Math.abs(change) < 5e-3 ? `${label} flat` : `${label} little changed (${change > 0 ? "+" : "\u2212"}${formatPercent(Math.abs(change), 0)})`);
1985
+ } else parts.push(`${label} ${change > 0 ? "up" : "down"} ${formatPercent(Math.abs(change), 0)}`);
1892
1986
  };
1893
1987
  say("Revenue", data.revenue, previous?.revenue, "money");
1894
1988
  say("Traffic", data.vercelPageviews, previous?.vercelPageviews, "count");
@@ -1922,7 +2016,7 @@ function DataHealthPanel({ data, diagnostics }) {
1922
2016
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
1923
2017
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Heading, { size: 1, style: sectionHeading, children: "Pageviews, source against source" }),
1924
2018
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "The same unit on both sides. Vercel is cookieless and ungated; GA4 is consent-gated and blockable, so GA4 seeing fewer is expected." }),
1925
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ComparisonBar, { label: "Vercel pageviews", metric: data.vercelPageviews, max: pageviewMax, outOf: "Complete: counted server-side." }),
2019
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ComparisonBar, { label: "Vercel pageviews", metric: data.vercelPageviews, max: pageviewMax, outOf: "Blocked less than GA4, but not immune to it." }),
1926
2020
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ComparisonBar, { label: "GA4 pageviews", metric: data.ga4Pageviews, max: pageviewMax, outOf: "A subset of the bar above, not a rival measurement." })
1927
2021
  ] }),
1928
2022
  /* @__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: [
@@ -1967,7 +2061,7 @@ function DataHealthPanel({ data, diagnostics }) {
1967
2061
  /* @__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: 3, children: [
1968
2062
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "Vercel visitors" }),
1969
2063
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(MetricFigure, { metric: metricOr(data.vercelVisitors, OLDER_ROUTE), label: "Vercel visitors" }),
1970
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Counted server-side, so neither consent nor ad-blocking reduces it." })
2064
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "Vercel\u2019s own counter. Blocked far less often than Google Analytics, but not never \u2014 so read it as a floor on your real traffic." })
1971
2065
  ] }) }),
1972
2066
  /* @__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: 3, children: [
1973
2067
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Label, { size: 1, muted: true, children: "GA4 sessions" }),
@@ -2866,11 +2960,26 @@ var PanelBoundary = class extends import_react4.default.Component {
2866
2960
  function VisitorInsightsTool(props) {
2867
2961
  const apiBaseUrl = props.tool?.options?.apiBaseUrl ?? props.apiBaseUrl ?? "";
2868
2962
  const siteLabel = props.tool?.options?.siteLabel ?? props.siteLabel ?? "";
2869
- const [range, setRange] = (0, import_react4.useState)("week");
2870
- const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react4.useState)("week");
2871
- const [custom, setCustom] = (0, import_react4.useState)(() => ({ start: isoDaysAgo(30), end: isoDaysAgo(0) }));
2963
+ const [linked] = (0, import_react4.useState)(() => typeof window === "undefined" ? {} : decodeView(window.location.hash));
2964
+ const linkedRange = RANGES.some((r) => r.key === linked.range) ? linked.range : null;
2965
+ const linkedTab = PANELS.some((p) => p.id === linked.tab) ? linked.tab : null;
2966
+ const [range, setRange] = (0, import_react4.useState)(linkedRange ?? "week");
2967
+ const [rangeBeforeBrush, setRangeBeforeBrush] = (0, import_react4.useState)(linkedRange ?? "week");
2968
+ const [custom, setCustom] = (0, import_react4.useState)(() => linked.from && linked.to ? { start: linked.from, end: linked.to } : { start: isoDaysAgo(30), end: isoDaysAgo(0) });
2872
2969
  const atPresent = custom.end >= isoDaysAgo(0);
2873
- const [activePanel, setActivePanel] = (0, import_react4.useState)("overview");
2970
+ const [activePanel, setActivePanel] = (0, import_react4.useState)(linkedTab ?? "overview");
2971
+ import_react4.default.useEffect(() => {
2972
+ if (typeof window === "undefined") return;
2973
+ const next = mergeIntoHash(window.location.hash, {
2974
+ tab: activePanel,
2975
+ range,
2976
+ from: custom.start,
2977
+ to: custom.end
2978
+ });
2979
+ if (next !== window.location.hash) {
2980
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${next}`);
2981
+ }
2982
+ }, [activePanel, range, custom.start, custom.end]);
2874
2983
  const active = PANELS.find((p) => p.id === activePanel) ?? PANELS[0];
2875
2984
  return (
2876
2985
  // Named for assistive technology even though the name is not printed: a Studio user with
@@ -3176,12 +3285,15 @@ var index_default = visitorInsights;
3176
3285
  TypefaceInterestPanel,
3177
3286
  VisitorInsightsTool,
3178
3287
  coverageForRange,
3288
+ decodeView,
3289
+ encodeView,
3179
3290
  estimated,
3180
3291
  formatCount,
3181
3292
  formatMoney,
3182
3293
  formatPercent,
3183
3294
  isReportName,
3184
3295
  knownShortfall,
3296
+ mergeIntoHash,
3185
3297
  ok,
3186
3298
  partial,
3187
3299
  previousRange,