@liiift-studio/sanity-visitor-insights 0.2.2 → 0.3.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/README.md CHANGED
@@ -37,6 +37,13 @@ like with like and labels the remainder unexplained.
37
37
  the GA4/Vercel gap into a real number. Until a site instruments it, the panel says so instead of
38
38
  guessing.
39
39
 
40
+ **Outages are recorded, not just start dates.** An event can exist in the code and still stop
41
+ reaching GA4 — Darden's ecommerce events did exactly that for nine months after a script-loading
42
+ change. Every one of those days returns an honest zero from the API, and a yearly funnel would
43
+ render it as a collapse in trade rather than a gap in measurement. `eventCutovers` therefore accepts
44
+ `{ from, outages: [{ start, until, reason }] }`, and a range inside an outage reports as unavailable
45
+ rather than as zero.
46
+
40
47
  **A missing metric is never zero.** Every figure is a `MetricValue` tagged union, so an
41
48
  uninstrumented event cannot silently coerce to `0` and be charted as a real trough. Unavailable
42
49
  figures render as an em dash with a stated reason.
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-DSFapooC.mjs';
4
- export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-DSFapooC.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-Bh9WCyYL.mjs';
4
+ export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-Bh9WCyYL.mjs';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-DSFapooC.js';
4
- export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-DSFapooC.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, D as DiagnosticReport, J as JourneyData, c as MeasurementHealthData, T as TypefaceInterestData } from './ranges-Bh9WCyYL.js';
4
+ export { C as Coverage, d as DateRange, E as EventCutover, P as PREEXISTING, e as REPORT_NAMES, f as ReportError, S as SiteAnalyticsConfig, g as SourceName, h as SourceStatus, U as UnavailableReason, i as coverageForRange, j as isReportName, o as ok, p as partial, k as previousRange, r as resolveRange, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-Bh9WCyYL.js';
5
5
 
6
6
  /**
7
7
  * The Visitor Insights Studio tool.
package/dist/index.js CHANGED
@@ -111,6 +111,7 @@ var REASON_TEXT = {
111
111
  not_instrumented: "Not tracked on this site",
112
112
  before_cutover: "Not tracked during this period",
113
113
  suppressed: "Withheld by GA4 for privacy",
114
+ outage: "Not recorded during part of this period",
114
115
  source_error: "Source did not respond",
115
116
  not_applicable: "Does not apply to this site"
116
117
  };
@@ -532,15 +533,33 @@ function isReportName(value) {
532
533
 
533
534
  // src/core/cutover.ts
534
535
  var PREEXISTING = "preexisting";
536
+ function toHistory(cutover) {
537
+ if (cutover === null || cutover === void 0) return null;
538
+ if (typeof cutover === "string") return { from: cutover };
539
+ if (!cutover.from) return null;
540
+ return cutover;
541
+ }
542
+ function overlaps(aStart, aEnd, bStart, bEnd) {
543
+ return aStart <= bEnd && (aEnd === null || aEnd >= bStart);
544
+ }
535
545
  function coverageForRange(cutovers, eventName, range) {
536
546
  if (!(eventName in cutovers)) return { status: "none", reason: "unknown_event" };
537
- const cutover = cutovers[eventName];
538
- if (cutover === null || cutover === void 0) return { status: "none", reason: "not_instrumented" };
539
- if (cutover === PREEXISTING) return { status: "full" };
540
- const cutoverTime = Date.parse(cutover);
541
- if (Number.isNaN(cutoverTime)) return { status: "none", reason: "unknown_event" };
542
- if (Date.parse(range.end) < cutoverTime) return { status: "none", reason: "before_cutover", cutover };
543
- if (Date.parse(range.start) < cutoverTime) return { status: "partial", cutover };
547
+ const history = toHistory(cutovers[eventName]);
548
+ if (!history) return { status: "none", reason: "not_instrumented" };
549
+ let cutover;
550
+ let startsBeforeCutover = false;
551
+ if (history.from !== PREEXISTING) {
552
+ const cutoverTime = Date.parse(history.from);
553
+ if (Number.isNaN(cutoverTime)) return { status: "none", reason: "unknown_event" };
554
+ cutover = history.from;
555
+ if (Date.parse(range.end) < cutoverTime) return { status: "none", reason: "before_cutover", cutover };
556
+ startsBeforeCutover = Date.parse(range.start) < cutoverTime;
557
+ }
558
+ const hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end));
559
+ const swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end));
560
+ if (swallowed) return { status: "none", reason: "outage", outages: [swallowed], cutover };
561
+ if (hit.length > 0) return { status: "partial", reason: "spans_outage", outages: hit, cutover };
562
+ if (startsBeforeCutover) return { status: "partial", reason: "spans_cutover", outages: [], cutover };
544
563
  return { status: "full" };
545
564
  }
546
565
 
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/studio/VisitorInsightsTool.tsx","../src/studio/useReport.ts","../src/studio/Figure.tsx","../src/studio/panels.tsx","../src/types.ts","../src/core/cutover.ts","../src/core/siteConfig.ts","../src/core/ranges.ts"],"sourcesContent":["/**\n * Studio entry point — browser only.\n *\n * Must never import from `./server` or anything under `src/server/`, which reads credentials and\n * uses node:crypto. The split export subpaths are what keep that guarantee enforceable.\n */\n\nimport { definePlugin } from 'sanity'\nimport { resolveIcon } from '@liiift-studio/sanity-ui-compat/icons'\nimport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\n\n/** Chart glyph, resolved against whichever @sanity/icons major the Studio has installed. */\nconst ChartIcon = resolveIcon('ChartUpwardIcon', 'chart-upward')\n\n/** Options for the Studio plugin. */\nexport interface VisitorInsightsPluginOptions {\n\t/**\n\t * Base URL of the site serving the reports, e.g. `https://dardenstudio.com`.\n\t * Same-origin studios can pass an empty string.\n\t */\n\tapiBaseUrl: string\n\t/** Label shown in the tool header. */\n\tsiteLabel: string\n\t/** Tool name in the Studio URL. Defaults to `visitor-insights`. */\n\tname?: string\n\t/** Title in the Studio nav. Defaults to `Insights`. */\n\ttitle?: string\n\t/**\n\t * Restrict the tool to these Sanity roles. Omit to show it to every Studio user.\n\t * These panels read order-derived conversion figures, so gating to administrators is\n\t * usually right — matching how the deploy and utilities tools are already gated.\n\t */\n\troles?: string[]\n}\n\n/**\n * Visitor Insights — visitor-behaviour analytics inside the Studio.\n *\n * @example\n * ```ts\n * plugins: [\n * visitorInsights({\n * apiBaseUrl: 'https://dardenstudio.com',\n * siteLabel: 'Darden Studio',\n * roles: ['administrator'],\n * }),\n * ]\n * ```\n */\nexport const visitorInsights = definePlugin<VisitorInsightsPluginOptions>((options) => {\n\tconst { apiBaseUrl, siteLabel, name = 'visitor-insights', title = 'Insights', roles } = options\n\n\treturn {\n\t\tname: '@liiift-studio/sanity-visitor-insights',\n\t\ttools: (prev, { currentUser }) => {\n\t\t\tif (roles && roles.length > 0) {\n\t\t\t\tconst userRoles = currentUser?.roles?.map((role) => role.name) ?? []\n\t\t\t\tconst permitted = userRoles.some((role) => roles.includes(role))\n\t\t\t\tif (!permitted) return prev\n\t\t\t}\n\n\t\t\treturn [\n\t\t\t\t...prev,\n\t\t\t\t{\n\t\t\t\t\tname,\n\t\t\t\t\ttitle,\n\t\t\t\t\ticon: ChartIcon,\n\t\t\t\t\tcomponent: VisitorInsightsTool,\n\t\t\t\t\toptions: { apiBaseUrl, siteLabel } satisfies VisitorInsightsToolProps,\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\t}\n})\n\nexport default visitorInsights\n\nexport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\nexport { useReport, type ReportState, type UseReportOptions } from './studio/useReport'\nexport { MetricFigure, ComparisonBar, NoticeList, formatCount, formatPercent } from './studio/Figure'\nexport {\n\tAcquisitionPanel,\n\tDiagnosticsPanel,\n\tJourneyPanel,\n\tMeasurementHealthPanel,\n\tTypefaceInterestPanel,\n} from './studio/panels'\n\n// Shared contract types, safe in the browser — no server code reachable from here.\nexport * from './types'\nexport { PREEXISTING, coverageForRange, type EventCutover, type Coverage } from './core/cutover'\nexport { validateSiteConfig, type SiteAnalyticsConfig } from './core/siteConfig'\nexport { resolveRange, previousRange } from './core/ranges'\n","/**\n * The Visitor Insights Studio tool.\n *\n * One range control drives every panel, implemented once here as a proper radiogroup with\n * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up\n * inconsistent between them.\n */\n\nimport React, { useCallback, useRef, useState } from 'react'\nimport { Box, Button, Card, Container, Flex, Heading, Spinner, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport type { RangeKey, ReportName } from '../types'\nimport { useReport } from './useReport'\nimport { NoticeList } from './Figure'\nimport { AcquisitionPanel, DiagnosticsPanel, JourneyPanel, MeasurementHealthPanel, TypefaceInterestPanel } from './panels'\n\n/** Range options, in the order they appear. */\nconst RANGES: Array<{ key: RangeKey; label: string }> = [\n\t{ key: 'week', label: 'Week' },\n\t{ key: 'quarter', label: 'Quarter' },\n\t{ key: 'year', label: 'Year' },\n]\n\n/** Panels, in the order they appear. */\nconst PANELS: Array<{ report: ReportName; label: string; blurb: string }> = [\n\t{ report: 'measurement-health', label: 'Measurement health', blurb: 'How much of reality each source actually sees' },\n\t{ report: 'acquisition', label: 'Acquisition', blurb: 'Where visitors come from' },\n\t{ report: 'journey', label: 'Journey', blurb: 'How far visitors get, and where they stop' },\n\t{ report: 'typeface-interest', label: 'Typeface interest', blurb: 'Viewed, tested and bought, by family' },\n\t{ report: 'diagnostics', label: 'Diagnostics', blurb: 'What to fix before trusting the numbers above' },\n]\n\n/** Props supplied by the plugin config. */\nexport interface VisitorInsightsToolProps {\n\tapiBaseUrl: string\n\tsiteLabel: string\n}\n\n/**\n * Range selector.\n * Arrow keys move between options and select as they go, which is the expected behaviour for a\n * radiogroup; Tab enters and leaves the group as a single stop.\n */\nfunction RangeSelector({ value, onChange }: { value: RangeKey; onChange: (next: RangeKey) => void }): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = RANGES.findIndex((r) => r.key === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % RANGES.length\n\t\t\tif (event.key === 'ArrowLeft' || event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + RANGES.length) % RANGES.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = RANGES.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = RANGES[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tonChange(next.key)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<Flex role=\"radiogroup\" aria-label=\"Date range\" gap={1} onKeyDown={onKeyDown}>\n\t\t\t{RANGES.map((range, index) => {\n\t\t\t\tconst selected = range.key === value\n\t\t\t\treturn (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tkey={range.key}\n\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t}}\n\t\t\t\t\t\trole=\"radio\"\n\t\t\t\t\t\taria-checked={selected}\n\t\t\t\t\t\t// Only the selected option is in the tab order, so the group is one stop.\n\t\t\t\t\t\ttabIndex={selected ? 0 : -1}\n\t\t\t\t\t\tmode={selected ? 'default' : 'bleed'}\n\t\t\t\t\t\ttone={selected ? 'primary' : 'default'}\n\t\t\t\t\t\ttext={range.label}\n\t\t\t\t\t\tonClick={() => onChange(range.key)}\n\t\t\t\t\t\tfontSize={1}\n\t\t\t\t\t\tpadding={3}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t})}\n\t\t</Flex>\n\t)\n}\n\n/**\n * Panel tabs.\n *\n * Hand-rolled rather than taken from @sanity/ui so the keyboard contract is explicit and identical\n * across the Studio versions this package supports: arrow keys move and select, Tab is a single\n * stop into the group, and each tab is wired to its panel by id.\n */\nfunction PanelTabs({ value, onChange }: { value: ReportName; onChange: (next: ReportName) => void }): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = PANELS.findIndex((p) => p.report === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % PANELS.length\n\t\t\tif (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + PANELS.length) % PANELS.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = PANELS.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = PANELS[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tonChange(next.report)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<Flex role=\"tablist\" aria-label=\"Report\" gap={1} wrap=\"wrap\" onKeyDown={onKeyDown}>\n\t\t\t{PANELS.map((panel, index) => {\n\t\t\t\tconst selected = panel.report === value\n\t\t\t\treturn (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tkey={panel.report}\n\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tid={`tab-${panel.report}`}\n\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\taria-selected={selected}\n\t\t\t\t\t\taria-controls={`panel-${panel.report}`}\n\t\t\t\t\t\ttabIndex={selected ? 0 : -1}\n\t\t\t\t\t\tmode={selected ? 'default' : 'bleed'}\n\t\t\t\t\t\ttone={selected ? 'primary' : 'default'}\n\t\t\t\t\t\ttext={panel.label}\n\t\t\t\t\t\tonClick={() => onChange(panel.report)}\n\t\t\t\t\t\tfontSize={1}\n\t\t\t\t\t\tpadding={3}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t})}\n\t\t</Flex>\n\t)\n}\n\n/** Renders one report panel, including its loading, error and empty states. */\nfunction ReportPanel({ report, apiBaseUrl, range }: { report: ReportName; apiBaseUrl: string; range: RangeKey }): React.ReactElement {\n\tconst { state, reload } = useReport<unknown>({ apiBaseUrl, report, range })\n\n\t// Announced to screen readers when figures change, so a range switch is perceivable without\n\t// re-navigating the whole panel.\n\tconst liveMessage =\n\t\tstate.status === 'loading'\n\t\t\t? 'Loading report'\n\t\t\t: state.status === 'ready'\n\t\t\t\t? `${report} updated for the selected ${range}`\n\t\t\t\t: state.status === 'error'\n\t\t\t\t\t? `Report failed: ${state.message}`\n\t\t\t\t\t: ''\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Box aria-live=\"polite\" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }}>\n\t\t\t\t{liveMessage}\n\t\t\t</Box>\n\n\t\t\t{state.status === 'loading' && (\n\t\t\t\t<Flex align=\"center\" gap={3} padding={4}>\n\t\t\t\t\t<Spinner muted />\n\t\t\t\t\t<Text size={1} muted>Loading…</Text>\n\t\t\t\t</Flex>\n\t\t\t)}\n\n\t\t\t{state.status === 'error' && (\n\t\t\t\t<Card padding={4} radius={2} tone=\"critical\" border>\n\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t<Text size={1}>{state.message}</Text>\n\t\t\t\t\t\t<Box>\n\t\t\t\t\t\t\t<Button text=\"Try again\" mode=\"ghost\" fontSize={1} onClick={reload} />\n\t\t\t\t\t\t</Box>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t{state.status === 'ready' && (\n\t\t\t\t<Stack space={4}>\n\t\t\t\t\t<NoticeList notices={state.envelope.notices} />\n\n\t\t\t\t\t{report === 'measurement-health' && <MeasurementHealthPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'acquisition' && <AcquisitionPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'journey' && <JourneyPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'typeface-interest' && <TypefaceInterestPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'diagnostics' && <DiagnosticsPanel data={state.envelope.data as never} />}\n\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{state.envelope.range.start} to {state.envelope.range.end} ({state.envelope.range.timezone})\n\t\t\t\t\t</Text>\n\t\t\t\t</Stack>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** The tool itself. */\nexport function VisitorInsightsTool({ apiBaseUrl, siteLabel }: VisitorInsightsToolProps): React.ReactElement {\n\tconst [range, setRange] = useState<RangeKey>('week')\n\tconst [activePanel, setActivePanel] = useState<ReportName>('measurement-health')\n\n\tconst active = PANELS.find((p) => p.report === activePanel) ?? PANELS[0]\n\n\treturn (\n\t\t<Container width={4} padding={4}>\n\t\t\t<Stack space={5}>\n\t\t\t\t<Flex align=\"flex-start\" justify=\"space-between\" gap={4} wrap=\"wrap\">\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Heading size={2}>Visitor insights</Heading>\n\t\t\t\t\t\t<Text size={1} muted>{siteLabel}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t\t<RangeSelector value={range} onChange={setRange} />\n\t\t\t\t</Flex>\n\n\t\t\t\t<PanelTabs value={activePanel} onChange={setActivePanel} />\n\n\t\t\t\t<Box\n\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\tid={`panel-${activePanel}`}\n\t\t\t\t\taria-labelledby={`tab-${activePanel}`}\n\t\t\t\t\t// Focusable so a keyboard user can Tab straight from the tab into its content.\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\tstyle={{ position: 'relative' }}\n\t\t\t\t>\n\t\t\t\t\t<Stack space={4}>\n\t\t\t\t\t\t{active && <Text size={1} muted>{active.blurb}</Text>}\n\t\t\t\t\t\t<ReportPanel report={activePanel} apiBaseUrl={apiBaseUrl} range={range} />\n\t\t\t\t\t</Stack>\n\t\t\t\t</Box>\n\t\t\t</Stack>\n\t\t</Container>\n\t)\n}\n","/**\n * Data hook for the Studio panels.\n *\n * Forwards the Studio's own Sanity session token to the site's API route, which verifies it against\n * Sanity. There is no shared secret: the Studio bundle is public, so anything compiled into it is\n * extractable.\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useClient } from 'sanity'\nimport type { RangeKey, ReportEnvelope, ReportName } from '../types'\n\n/** Sanity API version this tool pins. Fixed rather than \"latest\" so behaviour cannot drift. */\nconst API_VERSION = '2024-03-01'\n\n/** Loading state for a report. */\nexport type ReportState<T> =\n\t| { status: 'idle' }\n\t| { status: 'loading' }\n\t| { status: 'ready'; envelope: ReportEnvelope<T> }\n\t| { status: 'error'; message: string }\n\n/** Options for useReport. */\nexport interface UseReportOptions {\n\t/** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */\n\tapiBaseUrl: string\n\treport: ReportName\n\trange: RangeKey\n}\n\n/**\n * Fetch a report, re-fetching when the range changes.\n *\n * @returns the current state plus a `reload` for manual refresh\n */\nexport function useReport<T>({ apiBaseUrl, report, range }: UseReportOptions): {\n\tstate: ReportState<T>\n\treload: () => void\n} {\n\tconst client = useClient({ apiVersion: API_VERSION })\n\tconst [state, setState] = useState<ReportState<T>>({ status: 'idle' })\n\tconst [nonce, setNonce] = useState(0)\n\n\t// Lets an in-flight response from a previous range be discarded rather than overwriting a newer one.\n\tconst requestIdRef = useRef(0)\n\n\tconst reload = useCallback(() => setNonce((n) => n + 1), [])\n\n\tuseEffect(() => {\n\t\tconst requestId = requestIdRef.current + 1\n\t\trequestIdRef.current = requestId\n\n\t\tconst controller = new AbortController()\n\t\tsetState({ status: 'loading' })\n\n\t\tasync function run() {\n\t\t\t// The Studio client carries the session token under token-based auth. Under cookie-based\n\t\t\t// auth it does not, and there is no way to forward credentials the browser will not\n\t\t\t// expose — so say so plainly rather than failing with an opaque 401.\n\t\t\tconst token = client.config().token\n\t\t\tif (!token) {\n\t\t\t\tsetState({\n\t\t\t\t\tstatus: 'error',\n\t\t\t\t\tmessage: 'No Sanity session token available in this Studio. Visitor Insights needs token-based auth to call the site API.',\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst url = `${apiBaseUrl.replace(/\\/$/, '')}/api/visitor-insights/${report}?range=${range}`\n\t\t\t\tconst response = await fetch(url, {\n\t\t\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t})\n\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tconst detail = response.status === 401 ? 'Not authorised — sign in to the Studio again.' : `Request failed (${response.status})`\n\t\t\t\t\tsetState({ status: 'error', message: detail })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tconst envelope = (await response.json()) as ReportEnvelope<T>\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tsetState({ status: 'ready', envelope })\n\t\t\t} catch (e) {\n\t\t\t\tif (controller.signal.aborted || requestIdRef.current !== requestId) return\n\t\t\t\tsetState({ status: 'error', message: (e as Error).message })\n\t\t\t}\n\t\t}\n\n\t\tvoid run()\n\t\treturn () => controller.abort()\n\t}, [client, apiBaseUrl, report, range, nonce])\n\n\treturn { state, reload }\n}\n","/**\n * Shared renderers for metric values and comparison bars.\n *\n * Two rules are enforced here rather than left to each panel:\n *\n * 1. An unavailable metric renders as an em dash with a stated reason — never as \"0\". A missing\n * measurement and a measured zero mean opposite things, and must never look alike.\n * 2. Nothing is distinguished by colour alone. Bars carry a text value, a percentage and an\n * accessible label, so the figure survives greyscale, colour blindness and both Studio themes.\n */\n\nimport React from 'react'\nimport { Badge, Box, Flex, Stack, Text, Tooltip } from '@liiift-studio/sanity-ui-compat'\nimport type { MetricValue, UnavailableReason } from '../types'\n\n/** Human-readable explanation for each unavailable reason. */\nconst REASON_TEXT: Record<UnavailableReason, string> = {\n\tnot_instrumented: 'Not tracked on this site',\n\tbefore_cutover: 'Not tracked during this period',\n\tsuppressed: 'Withheld by GA4 for privacy',\n\tsource_error: 'Source did not respond',\n\tnot_applicable: 'Does not apply to this site',\n}\n\n/** Format a number with thousands separators. */\nexport function formatCount(value: number): string {\n\treturn new Intl.NumberFormat('en-GB').format(Math.round(value))\n}\n\n/** Format a 0–1 ratio as a percentage. */\nexport function formatPercent(ratio: number, digits = 0): string {\n\treturn `${(ratio * 100).toFixed(digits)}%`\n}\n\n/** Props for MetricFigure. */\nexport interface MetricFigureProps {\n\tmetric: MetricValue\n\t/** Accessible label describing what this number counts. */\n\tlabel: string\n\tsize?: number\n}\n\n/**\n * Render a metric value, handling the absent case visibly.\n * Screen readers get the reason text rather than an unexplained dash.\n */\nexport function MetricFigure({ metric, label, size = 4 }: MetricFigureProps): React.ReactElement {\n\tif (metric.status === 'unavailable') {\n\t\tconst reason = REASON_TEXT[metric.reason]\n\t\tconst detail = metric.detail ? `${reason}. ${metric.detail}` : reason\n\n\t\treturn (\n\t\t\t<Tooltip content={<Box padding={2}><Text size={1}>{detail}</Text></Box>} portal>\n\t\t\t\t<Text size={size} muted aria-label={`${label}: unavailable. ${detail}`}>\n\t\t\t\t\t<span aria-hidden=\"true\">—</span>\n\t\t\t\t</Text>\n\t\t\t</Tooltip>\n\t\t)\n\t}\n\n\tconst formatted = formatCount(metric.value)\n\n\tif (metric.status === 'partial') {\n\t\treturn (\n\t\t\t<Stack space={2}>\n\t\t\t\t<Text size={size} aria-label={`${label}: ${formatted}, partial. ${metric.note}`}>\n\t\t\t\t\t{formatted}\n\t\t\t\t</Text>\n\t\t\t\t<Badge tone=\"caution\" fontSize={0}>Partial</Badge>\n\t\t\t</Stack>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Text size={size} aria-label={`${label}: ${formatted}`}>\n\t\t\t{formatted}\n\t\t</Text>\n\t)\n}\n\n/** Props for ComparisonBar. */\nexport interface ComparisonBarProps {\n\tlabel: string\n\tmetric: MetricValue\n\t/** Largest value across the sibling bars, used to scale width. */\n\tmax: number\n\t/** Tone conveys category, but never carries meaning on its own. */\n\ttone?: 'primary' | 'positive' | 'caution' | 'default'\n}\n\n/**\n * A horizontal bar with its value printed alongside.\n *\n * Deliberately CSS rather than a charting library: these panels compare and rank a handful of\n * values, which a labelled bar does as well as a chart while avoiding a large dependency in the\n * Studio bundle and the theme-token bridging that a chart library would need for light and dark.\n */\nexport function ComparisonBar({ label, metric, max, tone = 'default' }: ComparisonBarProps): React.ReactElement {\n\tconst value = metric.status === 'unavailable' ? null : metric.value\n\tconst width = value !== null && max > 0 ? Math.max(2, (value / max) * 100) : 0\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<Flex align=\"center\" justify=\"space-between\" gap={3}>\n\t\t\t\t<Text size={1} weight=\"medium\">{label}</Text>\n\t\t\t\t<MetricFigure metric={metric} label={label} size={1} />\n\t\t\t</Flex>\n\n\t\t\t{value === null ? (\n\t\t\t\t// A dashed rail, not a zero-width bar: absence must not read as a measured zero.\n\t\t\t\t<Box\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: 8,\n\t\t\t\t\t\tborderRadius: 4,\n\t\t\t\t\t\tborder: '1px dashed var(--card-border-color)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t<Box\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: 8,\n\t\t\t\t\t\tborderRadius: 4,\n\t\t\t\t\t\tbackground: 'var(--card-border-color)',\n\t\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Box\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\twidth: `${width}%`,\n\t\t\t\t\t\t\theight: '100%',\n\t\t\t\t\t\t\tbackground:\n\t\t\t\t\t\t\t\ttone === 'primary'\n\t\t\t\t\t\t\t\t\t? 'var(--card-focus-ring-color)'\n\t\t\t\t\t\t\t\t\t: tone === 'positive'\n\t\t\t\t\t\t\t\t\t\t? 'var(--card-badge-positive-dot-color, var(--card-focus-ring-color))'\n\t\t\t\t\t\t\t\t\t\t: tone === 'caution'\n\t\t\t\t\t\t\t\t\t\t\t? 'var(--card-badge-caution-dot-color, var(--card-muted-fg-color))'\n\t\t\t\t\t\t\t\t\t\t\t: 'var(--card-muted-fg-color)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</Box>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Props for NoticeList. */\nexport interface NoticeListProps {\n\tnotices: string[]\n}\n\n/**\n * Caveats attached to a report — sampling, processing lag, instrumentation cutovers.\n * Rendered in the panel rather than a README: a caveat nobody sees does not prevent a wrong read.\n */\nexport function NoticeList({ notices }: NoticeListProps): React.ReactElement | null {\n\tif (notices.length === 0) return null\n\n\t// A real list element, so a screen reader announces how many caveats there are. The compat\n\t// shim's Stack does not forward `as`, so the semantics are set on plain elements here.\n\treturn (\n\t\t<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 8 }}>\n\t\t\t{notices.map((notice) => (\n\t\t\t\t<li key={notice}>\n\t\t\t\t\t<Flex gap={2} align=\"flex-start\">\n\t\t\t\t\t\t<Text size={1} muted aria-hidden=\"true\">⚠</Text>\n\t\t\t\t\t\t<Text size={1} muted>{notice}</Text>\n\t\t\t\t\t</Flex>\n\t\t\t\t</li>\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n","/**\n * The four report panels.\n *\n * Every panel renders a table or labelled bars rather than a chart, and every one surfaces its own\n * caveats inline. Tables are the accessible representation as well as the visual one, so there is\n * no separate \"view as data\" toggle that could drift out of sync with what is displayed.\n */\n\nimport React from 'react'\nimport { Box, Card, Flex, Heading, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport { ComparisonBar, MetricFigure, NoticeList, formatCount, formatPercent } from './Figure'\nimport type {\n\tAcquisitionData,\n\tCheckStatus,\n\tDiagnosticReport,\n\tJourneyData,\n\tMeasurementHealthData,\n\tTypefaceInterestData,\n} from '../reportData'\nimport type { MetricValue } from '../types'\n\n/** Largest available value across metrics, for scaling bars. */\nfunction maxOf(metrics: MetricValue[]): number {\n\treturn metrics.reduce((max, metric) => (metric.status === 'unavailable' ? max : Math.max(max, metric.value)), 0)\n}\n\n/** Shared table styling — scrolls inside its own container so the panel never scrolls sideways. */\nconst tableWrap: React.CSSProperties = { overflowX: 'auto', width: '100%' }\nconst table: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', minWidth: 420 }\nconst cell: React.CSSProperties = { padding: '8px 12px', textAlign: 'left', borderBottom: '1px solid var(--card-border-color)' }\nconst numericCell: React.CSSProperties = { ...cell, textAlign: 'right' }\n\n/**\n * Measurement Health — how much of reality each source sees.\n * Compares pageviews to pageviews. Sessions and orders sit alongside as context and are never\n * subtracted from a pageview count.\n */\nexport function MeasurementHealthPanel({ data }: { data: MeasurementHealthData }): React.ReactElement {\n\tconst pageviewMax = maxOf([data.ga4Pageviews, data.vercelPageviews])\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Stack space={3}>\n\t\t\t\t<Heading size={1}>Pageviews, source against source</Heading>\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tThe same unit on both sides. Vercel is cookieless and ungated; GA4 is consent-gated and\n\t\t\t\t\tblockable, so GA4 seeing fewer is expected.\n\t\t\t\t</Text>\n\t\t\t\t<ComparisonBar label=\"Vercel pageviews\" metric={data.vercelPageviews} max={pageviewMax} tone=\"primary\" />\n\t\t\t\t<ComparisonBar label=\"GA4 pageviews\" metric={data.ga4Pageviews} max={pageviewMax} tone=\"default\" />\n\t\t\t</Stack>\n\n\t\t\t{data.shortfallRatio !== null && (\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\" border>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} weight=\"semibold\">\n\t\t\t\t\t\t\tGA4 shortfall: {formatPercent(Math.abs(data.shortfallRatio), 1)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text size={1} muted>{data.interpretation}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t<Stack space={3}>\n\t\t\t\t<Heading size={1}>Context</Heading>\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tDifferent units to the figures above, and to each other. Shown for scale, never differenced.\n\t\t\t\t</Text>\n\t\t\t\t<Flex gap={4} wrap=\"wrap\">\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>GA4 sessions</Text>\n\t\t\t\t\t\t<MetricFigure metric={data.ga4Sessions} label=\"GA4 sessions\" />\n\t\t\t\t\t</Stack>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>Orders</Text>\n\t\t\t\t\t\t<MetricFigure metric={data.orders} label=\"Orders\" />\n\t\t\t\t\t</Stack>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>Consent granted</Text>\n\t\t\t\t\t\t<MetricFigure metric={data.consentRate} label=\"Consent granted, percent of sessions\" />\n\t\t\t\t\t</Stack>\n\t\t\t\t</Flex>\n\t\t\t</Stack>\n\t\t</Stack>\n\t)\n}\n\n/** Acquisition — where visitors came from, with design-industry referrers called out. */\nexport function AcquisitionPanel({ data }: { data: AcquisitionData }): React.ReactElement {\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Flex gap={4} wrap=\"wrap\">\n\t\t\t\t<Stack space={2}>\n\t\t\t\t\t<Text size={1} muted>Sessions</Text>\n\t\t\t\t\t<Text size={4}>{formatCount(data.totalSessions)}</Text>\n\t\t\t\t</Stack>\n\t\t\t\t{data.designIndustryShare !== null && (\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>From design-industry referrers</Text>\n\t\t\t\t\t\t<Text size={4}>{formatPercent(data.designIndustryShare, 1)}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t)}\n\t\t\t\t{data.unattributedShare !== null && (\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>Unattributed</Text>\n\t\t\t\t\t\t<Text size={4}>{formatPercent(data.unattributedShare, 1)}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t)}\n\t\t\t</Flex>\n\n\t\t\t<Box style={tableWrap}>\n\t\t\t\t<table style={table}>\n\t\t\t\t\t<caption style={{ textAlign: 'left', paddingBottom: 8 }}>\n\t\t\t\t\t\t<Text size={1} muted>Traffic sources by sessions</Text>\n\t\t\t\t\t</caption>\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Source</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Channel</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Sessions</Text></th>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tbody>\n\t\t\t\t\t\t{data.rows.map((row) => (\n\t\t\t\t\t\t\t<tr key={`${row.source}-${row.channel}`}>\n\t\t\t\t\t\t\t\t<th scope=\"row\" style={cell}>\n\t\t\t\t\t\t\t\t\t<Flex gap={2} align=\"center\">\n\t\t\t\t\t\t\t\t\t\t<Text size={1}>{row.source}</Text>\n\t\t\t\t\t\t\t\t\t\t{row.designIndustry && <Text size={0} muted>· design industry</Text>}\n\t\t\t\t\t\t\t\t\t\t{row.unattributed && <Text size={0} muted>· unattributed</Text>}\n\t\t\t\t\t\t\t\t\t</Flex>\n\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t<td style={cell}><Text size={1} muted>{row.channel}</Text></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}><Text size={1}>{formatCount(row.sessions)}</Text></td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\t\t\t</Box>\n\n\t\t\t{data.rowsWithheld && (\n\t\t\t\t<NoticeList notices={['GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality.']} />\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Journey — per-step totals with adjacent drop-off, explicitly not a tracked path. */\nexport function JourneyPanel({ data }: { data: JourneyData }): React.ReactElement {\n\tconst max = maxOf(data.steps.map((step) => step.count))\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t<Text size={1}>{data.approximationNote}</Text>\n\t\t\t</Card>\n\n\t\t\t<Stack space={3}>\n\t\t\t\t{data.steps.map((step) => (\n\t\t\t\t\t<Stack space={2} key={step.key}>\n\t\t\t\t\t\t<ComparisonBar label={step.label} metric={step.count} max={max} tone=\"primary\" />\n\t\t\t\t\t\t{step.conversionFromPrevious !== null && (\n\t\t\t\t\t\t\t<Text size={0} muted>{formatPercent(step.conversionFromPrevious, 1)} of the previous measurable step</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Stack>\n\t\t\t\t))}\n\t\t\t</Stack>\n\n\t\t\t{data.topExitPages.length > 0 && (\n\t\t\t\t<Stack space={3}>\n\t\t\t\t\t<Heading size={1}>Where sessions ended</Heading>\n\t\t\t\t\t<Box style={tableWrap}>\n\t\t\t\t\t\t<table style={table}>\n\t\t\t\t\t\t\t<thead>\n\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Page</Text></th>\n\t\t\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Exits</Text></th>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t{data.topExitPages.map((page) => (\n\t\t\t\t\t\t\t\t\t<tr key={page.path}>\n\t\t\t\t\t\t\t\t\t\t<th scope=\"row\" style={cell}><Text size={1}>{page.path}</Text></th>\n\t\t\t\t\t\t\t\t\t\t<td style={numericCell}><Text size={1}>{formatCount(page.exits)}</Text></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t</table>\n\t\t\t\t\t</Box>\n\t\t\t\t</Stack>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Typeface interest — viewed, tested and bought per family. */\nexport function TypefaceInterestPanel({ data }: { data: TypefaceInterestData }): React.ReactElement {\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\" border>\n\t\t\t\t<Text size={1} muted>{data.interpretationNote}</Text>\n\t\t\t</Card>\n\n\t\t\t<Box style={tableWrap}>\n\t\t\t\t<table style={table}>\n\t\t\t\t\t<caption style={{ textAlign: 'left', paddingBottom: 8 }}>\n\t\t\t\t\t\t<Text size={1} muted>Engagement by typeface</Text>\n\t\t\t\t\t</caption>\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Typeface</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Viewed</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Tested</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Bought</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Test rate</Text></th>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tbody>\n\t\t\t\t\t\t{data.rows.map((row) => (\n\t\t\t\t\t\t\t<tr key={row.typeface}>\n\t\t\t\t\t\t\t\t<th scope=\"row\" style={cell}><Text size={1}>{row.typeface}</Text></th>\n\t\t\t\t\t\t\t\t<td style={numericCell}><MetricFigure metric={row.viewed} label={`${row.typeface} viewed`} size={1} /></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}><MetricFigure metric={row.tested} label={`${row.typeface} tested`} size={1} /></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}><MetricFigure metric={row.bought} label={`${row.typeface} bought`} size={1} /></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}>\n\t\t\t\t\t\t\t\t\t<Text size={1} muted>{row.testRate === null ? '—' : formatPercent(row.testRate, 1)}</Text>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\t\t\t</Box>\n\t\t</Stack>\n\t)\n}\n\n/** Sanity UI tone for each check status. Status is also always spelled out in text. */\nconst CHECK_TONE: Record<CheckStatus, 'positive' | 'caution' | 'critical' | 'default'> = {\n\tpass: 'positive',\n\twarn: 'caution',\n\tfail: 'critical',\n\tskipped: 'default',\n}\n\n/** Word shown alongside the tone, so status never depends on colour alone. */\nconst CHECK_WORD: Record<CheckStatus, string> = {\n\tpass: 'Pass',\n\twarn: 'Check',\n\tfail: 'Fail',\n\tskipped: 'Skipped',\n}\n\n/**\n * Diagnostics — what to fix before trusting anything else here.\n *\n * Deliberately the panel that still works with nothing configured: on a site without credentials\n * it is the only one that can say something useful, and it is the first thing worth opening once\n * credentials land.\n */\nexport function DiagnosticsPanel({ data }: { data: DiagnosticReport }): React.ReactElement {\n\tconst failing = data.checks.filter((c) => c.status === 'fail').length\n\tconst warning = data.checks.filter((c) => c.status === 'warn').length\n\n\tconst summary =\n\t\tdata.verdict === 'pass'\n\t\t\t? 'Everything checked out. The figures in the other panels can be taken at face value.'\n\t\t\t: `${failing} failing, ${warning} worth a look. Panels depending on these will be wrong or incomplete until they are resolved.`\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Card padding={3} radius={2} tone={CHECK_TONE[data.verdict]} border>\n\t\t\t\t<Text size={1}>{summary}</Text>\n\t\t\t</Card>\n\n\t\t\t<Stack space={3}>\n\t\t\t\t{data.checks.map((item) => (\n\t\t\t\t\t<Card key={item.id} padding={3} radius={2} tone=\"transparent\" border>\n\t\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t\t<Flex align=\"center\" justify=\"space-between\" gap={3}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"semibold\">{item.label}</Text>\n\t\t\t\t\t\t\t\t<Card padding={2} radius={2} tone={CHECK_TONE[item.status]}>\n\t\t\t\t\t\t\t\t\t<Text size={0} weight=\"medium\">{CHECK_WORD[item.status]}</Text>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t</Flex>\n\t\t\t\t\t\t\t<Text size={1} muted>{item.detail}</Text>\n\t\t\t\t\t\t\t{item.remedy && <Text size={1}>{item.remedy}</Text>}\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t</Card>\n\t\t\t\t))}\n\t\t\t</Stack>\n\t\t</Stack>\n\t)\n}\n","/** Shared contract types for the Studio plugin and the API-route handler it calls. */\n\n// ---------------------------------------------------------------------------\n// Metric values\n// ---------------------------------------------------------------------------\n\n/**\n * Why a metric has no usable number. Kept distinct rather than collapsed into one flag, because\n * the UI must say different things: \"this site never tracked it\" is permanent, \"not yet at this\n * date\" resolves as time passes, and \"GA4 withheld it\" means the number exists but is hidden.\n */\nexport type UnavailableReason =\n\t/** The event has never been instrumented on this site. Permanent for historical ranges. */\n\t| 'not_instrumented'\n\t/** Instrumented, but the requested range predates the cutover date. */\n\t| 'before_cutover'\n\t/** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */\n\t| 'suppressed'\n\t/** The upstream source errored or timed out. Retryable, unlike the others. */\n\t| 'source_error'\n\t/** The metric is not defined for this site at all (e.g. a flow that does not exist there). */\n\t| 'not_applicable'\n\n/**\n * A metric that may legitimately have no value.\n *\n * The whole point of this union is that there is no way to read a number without first handling\n * the absent case — a missing metric can never silently coerce to 0 and be charted as a real\n * trough. `partial` carries a number that is only valid for part of the requested range.\n */\nexport type MetricValue =\n\t| { status: 'ok'; value: number }\n\t| { status: 'partial'; value: number; coveredFrom: string; note: string }\n\t| { status: 'unavailable'; reason: UnavailableReason; detail?: string }\n\n/** Construct an available metric. */\nexport function ok(value: number): MetricValue {\n\treturn { status: 'ok', value }\n}\n\n/** Construct an unavailable metric. */\nexport function unavailable(reason: UnavailableReason, detail?: string): MetricValue {\n\treturn { status: 'unavailable', reason, detail }\n}\n\n/**\n * Construct a metric covering only part of the requested range.\n *\n * @param value - the number for the covered portion\n * @param coveredFrom - ISO date from which the number is valid\n * @param note - short human-readable reason, shown next to the figure\n */\nexport function partial(value: number, coveredFrom: string, note: string): MetricValue {\n\treturn { status: 'partial', value, coveredFrom, note }\n}\n\n/**\n * Read a metric's number, or `null` when there isn't a usable one.\n * Use at render time so the absent case has to be handled explicitly.\n */\nexport function valueOrNull(metric: MetricValue): number | null {\n\treturn metric.status === 'unavailable' ? null : metric.value\n}\n\n// ---------------------------------------------------------------------------\n// Date ranges\n// ---------------------------------------------------------------------------\n\n/** The range granularities offered in the UI. */\nexport type RangeKey = 'week' | 'quarter' | 'year'\n\n/**\n * A resolved date range. Both bounds are inclusive ISO `YYYY-MM-DD` dates in `timezone`.\n *\n * `timezone` is carried explicitly because GA4 buckets by the property's configured timezone,\n * Vercel reports in UTC, and Sanity `_createdAt` is UTC — comparing them without a single declared\n * anchor silently shifts events across day and week boundaries.\n */\nexport interface DateRange {\n\tkey: RangeKey\n\tstart: string\n\tend: string\n\ttimezone: string\n}\n\n// ---------------------------------------------------------------------------\n// Reports\n// ---------------------------------------------------------------------------\n\n/** The report names this package serves. Used as a strict allow-list, never as dynamic dispatch. */\nexport const REPORT_NAMES = [\n\t'measurement-health',\n\t'acquisition',\n\t'journey',\n\t'typeface-interest',\n\t'diagnostics',\n] as const\n\nexport type ReportName = (typeof REPORT_NAMES)[number]\n\n/** Narrow an untrusted string to a known report name. */\nexport function isReportName(value: unknown): value is ReportName {\n\treturn typeof value === 'string' && (REPORT_NAMES as readonly string[]).includes(value)\n}\n\n/** Which upstreams a report consulted, and how each fared. */\nexport type SourceStatus =\n\t| { status: 'ok' }\n\t| { status: 'unconfigured' }\n\t| { status: 'error'; message: string }\n\n/** Upstream data sources this package can read. */\nexport type SourceName = 'ga4' | 'vercel' | 'sanity'\n\n/**\n * Envelope around every report response.\n *\n * `sources` is mandatory rather than optional because partial failure is the normal case here,\n * not the exception: a panel comparing three sources must be able to distinguish \"this source\n * reported zero\" from \"this source never answered\", which a bare payload cannot express.\n */\nexport interface ReportEnvelope<T> {\n\treport: ReportName\n\trange: DateRange\n\t/** Per-source outcome. A report may succeed overall while one source is unconfigured. */\n\tsources: Partial<Record<SourceName, SourceStatus>>\n\t/** Caveats to surface in the UI, e.g. GA4 sampling or an instrumentation cutover in range. */\n\tnotices: string[]\n\tdata: T\n}\n\n/** Error shape returned by the handler. Never leaks upstream error text to the browser. */\nexport interface ReportError {\n\terror: string\n\treport?: string\n}\n","/**\n * Event instrumentation cutovers.\n *\n * GA4 cannot backfill events: data from before an event was instrumented does not exist and never\n * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn\n * across it reads as a change in visitor behaviour when it is really a change in what was counted.\n *\n * This lives in the package rather than in each site repo so all three foundries share one model\n * and one set of semantics, and so the Studio panel and the server handler agree by construction.\n */\n\nimport type { DateRange, MetricValue } from '../types'\nimport { unavailable, partial } from '../types'\n\n/** An event that predates this work — long-running, with no discontinuity to mark. */\nexport const PREEXISTING = 'preexisting' as const\n\n/**\n * When an event began firing on a site.\n * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.\n * An ISO `YYYY-MM-DD` string = the deploy date, set in the commit that ships the event.\n */\nexport type EventCutover = typeof PREEXISTING | string | null\n\n/** How completely an event covers a requested range. */\nexport type Coverage =\n\t| { status: 'full' }\n\t| { status: 'partial'; cutover: string }\n\t| { status: 'none'; reason: 'not_instrumented' | 'before_cutover' | 'unknown_event'; cutover?: string }\n\n/**\n * Determine how well an event covers a date range.\n *\n * @param cutovers - the site's event cutover map\n * @param eventName - GA4 event name to look up\n * @param range - the requested range\n */\nexport function coverageForRange(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventName: string,\n\trange: Pick<DateRange, 'start' | 'end'>,\n): Coverage {\n\tif (!(eventName in cutovers)) return { status: 'none', reason: 'unknown_event' }\n\n\tconst cutover = cutovers[eventName]\n\t// `in` proved the key exists, so undefined here means an explicitly undefined value.\n\tif (cutover === null || cutover === undefined) return { status: 'none', reason: 'not_instrumented' }\n\tif (cutover === PREEXISTING) return { status: 'full' }\n\n\tconst cutoverTime = Date.parse(cutover)\n\t// An unparseable date is a config error; treat it as unknown rather than silently trusting it.\n\tif (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }\n\n\tif (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }\n\tif (Date.parse(range.start) < cutoverTime) return { status: 'partial', cutover }\n\n\treturn { status: 'full' }\n}\n\n/**\n * Wrap a raw count in a MetricValue that reflects the event's coverage of the range.\n *\n * Call this instead of returning a bare number, so a value that only covers part of the range\n * carries that fact with it all the way to the renderer.\n *\n * @param count - the number GA4 returned, or null if the query could not run\n * @param coverage - result of coverageForRange for the same event and range\n */\nexport function applyCoverage(count: number | null, coverage: Coverage): MetricValue {\n\tif (coverage.status === 'none') {\n\t\treturn unavailable(\n\t\t\tcoverage.reason === 'before_cutover' ? 'before_cutover' : 'not_instrumented',\n\t\t\tcoverage.cutover ? `Instrumented from ${coverage.cutover}` : undefined,\n\t\t)\n\t}\n\n\tif (count === null) return unavailable('source_error')\n\n\tif (coverage.status === 'partial') {\n\t\treturn partial(count, coverage.cutover, `Only counted from ${coverage.cutover}, when this event was added`)\n\t}\n\n\treturn { status: 'ok', value: count }\n}\n\n/**\n * Human-readable notices for every event in a range that is not fully covered.\n * Surfaced in the report envelope so the panel can show caveats without recomputing them.\n */\nexport function coverageNotices(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventNames: readonly string[],\n\trange: Pick<DateRange, 'start' | 'end'>,\n): string[] {\n\tconst notices: string[] = []\n\n\tfor (const name of eventNames) {\n\t\tconst coverage = coverageForRange(cutovers, name, range)\n\t\tif (coverage.status === 'partial') {\n\t\t\tnotices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'not_instrumented') {\n\t\t\tnotices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'before_cutover') {\n\t\t\tnotices.push(`${name} did not exist during this range; it was instrumented on ${coverage.cutover}.`)\n\t\t}\n\t}\n\n\treturn notices\n}\n","/**\n * Per-site adapter configuration.\n *\n * The three foundries differ in ways that cannot be abstracted away: different GA4 event coverage,\n * different order schemas, different flows that exist on one site and not another. Rather than\n * branching on a site id inside the reports — which would mean editing this package to onboard a\n * fourth foundry — each site describes itself through this config and the reports stay generic.\n */\n\nimport type { EventCutover } from './cutover'\n\n/** GA4 connection for one site. */\nexport interface Ga4Config {\n\t/** Numeric GA4 property id. NOT the `G-XXXXXXX` measurement id, which is the client-side one. */\n\tpropertyId: string\n\t/** IANA timezone the property is configured with. Every range is anchored to this. */\n\ttimezone: string\n}\n\n/** Vercel Web Analytics connection for one site. */\nexport interface VercelConfig {\n\tprojectId: string\n\tteamId?: string\n}\n\n/** Where to count orders, and what they are called on this site. */\nexport interface OrdersConfig {\n\t/** Document type holding orders. */\n\tdocumentType: string\n\t/**\n\t * Field holding the typeface references on an order, if the site has one.\n\t * Null where orders do not resolve to typefaces in a usable way.\n\t */\n\ttypefacesField: string | null\n\t/**\n\t * GROQ filter appended to exclude non-typeface orders, e.g. merch-only orders on Darden.\n\t * Merch inflates a family's apparent purchase count if not excluded.\n\t */\n\texcludeFilter?: string\n}\n\n/** One site's complete description of itself. */\nexport interface SiteAnalyticsConfig {\n\t/** Stable machine id, e.g. `darden`. */\n\tsiteId: string\n\t/** Human label shown in the Studio UI. */\n\tlabel: string\n\t/** Null when GA4 is not wired up for this site — reports degrade rather than fail. */\n\tga4: Ga4Config | null\n\t/** Null when Vercel Web Analytics is unavailable, e.g. on a plan tier without API access. */\n\tvercel: VercelConfig | null\n\torders: OrdersConfig\n\t/**\n\t * When each GA4 event began firing here. Events absent from this map report as `unknown_event`,\n\t * which is the honest answer for a flow the site does not have — distinct from `not_instrumented`,\n\t * which implies it is merely pending.\n\t */\n\teventCutovers: Record<string, EventCutover>\n\t/** Origins allowed to call this site's handler cross-origin, e.g. a separately deployed Studio. */\n\tallowedStudioOrigins?: string[]\n}\n\n/** A problem found while validating a site config. */\nexport interface ConfigProblem {\n\tfield: string\n\tmessage: string\n}\n\n/**\n * Validate a site config at runtime.\n *\n * Compile-time types do not survive into a consuming site's `sanity.config.js`, which is plain\n * JavaScript — so a typo'd property id or a measurement id pasted where a numeric property id\n * belongs would otherwise surface as an empty chart rather than an error.\n *\n * @param config - the candidate config\n * @returns the problems found; an empty array means the config is usable\n */\nexport function validateSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): ConfigProblem[] {\n\tconst problems: ConfigProblem[] = []\n\n\tif (!config) {\n\t\treturn [{ field: 'config', message: 'No site config supplied' }]\n\t}\n\n\tif (!config.siteId) problems.push({ field: 'siteId', message: 'Required' })\n\tif (!config.label) problems.push({ field: 'label', message: 'Required' })\n\n\tif (config.ga4) {\n\t\tconst { propertyId, timezone } = config.ga4\n\t\tif (!propertyId) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Required when ga4 is configured' })\n\t\t} else if (propertyId.startsWith('G-')) {\n\t\t\tproblems.push({\n\t\t\t\tfield: 'ga4.propertyId',\n\t\t\t\tmessage: `Looks like a measurement id (\"${propertyId}\"). The Data API needs the numeric property id from GA4 Admin instead.`,\n\t\t\t})\n\t\t} else if (!/^\\d+$/.test(propertyId)) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Must be the numeric GA4 property id' })\n\t\t}\n\n\t\tif (!timezone) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: 'Required — ranges are anchored to it' })\n\t\t} else if (!isValidTimeZone(timezone)) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: `Not a recognised IANA timezone: ${timezone}` })\n\t\t}\n\t}\n\n\tif (config.vercel && !config.vercel.projectId) {\n\t\tproblems.push({ field: 'vercel.projectId', message: 'Required when vercel is configured' })\n\t}\n\n\tif (!config.orders?.documentType) {\n\t\tproblems.push({ field: 'orders.documentType', message: 'Required' })\n\t}\n\n\tif (!config.eventCutovers) {\n\t\tproblems.push({ field: 'eventCutovers', message: 'Required — omit events the site does not have' })\n\t}\n\n\tfor (const origin of config.allowedStudioOrigins ?? []) {\n\t\tif (!/^https?:\\/\\//.test(origin)) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must be a full origin including scheme: ${origin}` })\n\t\t}\n\t\tif (origin.endsWith('/')) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must not have a trailing slash: ${origin}` })\n\t\t}\n\t}\n\n\treturn problems\n}\n\n/** Whether a string is an IANA timezone this runtime recognises. */\nexport function isValidTimeZone(timeZone: string): boolean {\n\ttry {\n\t\tnew Intl.DateTimeFormat('en-CA', { timeZone })\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/** Throw if a config is unusable, naming every problem at once rather than one per run. */\nexport function assertValidSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): asserts config is SiteAnalyticsConfig {\n\tconst problems = validateSiteConfig(config)\n\tif (problems.length > 0) {\n\t\tconst detail = problems.map((p) => ` ${p.field}: ${p.message}`).join('\\n')\n\t\tthrow new Error(`Invalid visitor-insights site config:\\n${detail}`)\n\t}\n}\n","/**\n * Date-range resolution.\n *\n * Every range is resolved against one declared timezone — the GA4 property's — because the three\n * sources bucket time differently: GA4 by the property timezone, Vercel by UTC, Sanity `_createdAt`\n * by UTC. Without a single anchor, an order placed near midnight lands in different weeks depending\n * on which source you ask, which corrupts exactly the cross-source comparison this tool exists for.\n */\n\nimport type { DateRange, RangeKey } from '../types'\n\n/** GA4 does not finalise the most recent days. Figures inside this window may still rise. */\nexport const GA4_PROCESSING_LAG_DAYS = 2\n\n/** Format a Date as `YYYY-MM-DD` in the given IANA timezone. */\nexport function formatInTimeZone(date: Date, timeZone: string): string {\n\t// en-CA gives ISO-ordered parts, which is what we want to reassemble.\n\tconst parts = new Intl.DateTimeFormat('en-CA', {\n\t\ttimeZone,\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t}).formatToParts(date)\n\n\tconst get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''\n\treturn `${get('year')}-${get('month')}-${get('day')}`\n}\n\n/** Shift an ISO `YYYY-MM-DD` date by a whole number of days. */\nexport function shiftDays(isoDate: string, days: number): string {\n\tconst time = Date.parse(`${isoDate}T00:00:00Z`)\n\tif (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`)\n\treturn new Date(time + days * 86_400_000).toISOString().slice(0, 10)\n}\n\n/** Inclusive whole days between two ISO dates. */\nexport function daysBetween(start: string, end: string): number {\n\treturn Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000) + 1\n}\n\n/** How many days each range key spans. */\nconst RANGE_DAYS: Record<RangeKey, number> = {\n\tweek: 7,\n\tquarter: 91,\n\tyear: 365,\n}\n\n/**\n * Resolve a range key into concrete dates, anchored to `timezone` and ending today.\n *\n * @param key - week, quarter or year\n * @param timezone - IANA timezone of the GA4 property this range will be queried against\n * @param now - injectable clock, so tests are deterministic\n */\nexport function resolveRange(key: RangeKey, timezone: string, now: Date = new Date()): DateRange {\n\tconst end = formatInTimeZone(now, timezone)\n\tconst start = shiftDays(end, -(RANGE_DAYS[key] - 1))\n\treturn { key, start, end, timezone }\n}\n\n/**\n * The equivalent range immediately before `range`, for period-over-period comparison.\n *\n * A bare count answers \"how many\", which on its own is not actionable; the comparison is what\n * tells someone whether to do anything.\n */\nexport function previousRange(range: DateRange): DateRange {\n\tconst span = daysBetween(range.start, range.end)\n\treturn {\n\t\tkey: range.key,\n\t\tstart: shiftDays(range.start, -span),\n\t\tend: shiftDays(range.end, -span),\n\t\ttimezone: range.timezone,\n\t}\n}\n\n/**\n * The trailing dates within a range whose GA4 figures are not yet settled.\n * Returns an empty array when the range ends before the lag window.\n */\nexport function provisionalDates(range: DateRange, now: Date = new Date()): string[] {\n\tconst today = formatInTimeZone(now, range.timezone)\n\tconst dates: string[] = []\n\n\tfor (let i = 0; i < GA4_PROCESSING_LAG_DAYS; i += 1) {\n\t\tconst date = shiftDays(today, -i)\n\t\tif (date >= range.start && date <= range.end) dates.push(date)\n\t}\n\n\treturn dates.sort()\n}\n\n/**\n * Notice text when a range includes days GA4 has not finished processing, or `null` when it does not.\n * Without this a Monday-morning glance at \"this week\" reads the trailing dip as a real drop.\n */\nexport function provisionalNotice(range: DateRange, now: Date = new Date()): string | null {\n\tconst dates = provisionalDates(range, now)\n\tif (dates.length === 0) return null\n\n\tconst from = dates[0]\n\treturn `GA4 has not finished processing ${from} onwards; those figures are provisional and will rise.`\n}\n"],"mappings":"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","names":["import_sanity","import_react","import_sanity_ui_compat","import_sanity_ui_compat","import_jsx_runtime","import_jsx_runtime"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/studio/VisitorInsightsTool.tsx","../src/studio/useReport.ts","../src/studio/Figure.tsx","../src/studio/panels.tsx","../src/types.ts","../src/core/cutover.ts","../src/core/siteConfig.ts","../src/core/ranges.ts"],"sourcesContent":["/**\n * Studio entry point — browser only.\n *\n * Must never import from `./server` or anything under `src/server/`, which reads credentials and\n * uses node:crypto. The split export subpaths are what keep that guarantee enforceable.\n */\n\nimport { definePlugin } from 'sanity'\nimport { resolveIcon } from '@liiift-studio/sanity-ui-compat/icons'\nimport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\n\n/** Chart glyph, resolved against whichever @sanity/icons major the Studio has installed. */\nconst ChartIcon = resolveIcon('ChartUpwardIcon', 'chart-upward')\n\n/** Options for the Studio plugin. */\nexport interface VisitorInsightsPluginOptions {\n\t/**\n\t * Base URL of the site serving the reports, e.g. `https://dardenstudio.com`.\n\t * Same-origin studios can pass an empty string.\n\t */\n\tapiBaseUrl: string\n\t/** Label shown in the tool header. */\n\tsiteLabel: string\n\t/** Tool name in the Studio URL. Defaults to `visitor-insights`. */\n\tname?: string\n\t/** Title in the Studio nav. Defaults to `Insights`. */\n\ttitle?: string\n\t/**\n\t * Restrict the tool to these Sanity roles. Omit to show it to every Studio user.\n\t * These panels read order-derived conversion figures, so gating to administrators is\n\t * usually right — matching how the deploy and utilities tools are already gated.\n\t */\n\troles?: string[]\n}\n\n/**\n * Visitor Insights — visitor-behaviour analytics inside the Studio.\n *\n * @example\n * ```ts\n * plugins: [\n * visitorInsights({\n * apiBaseUrl: 'https://dardenstudio.com',\n * siteLabel: 'Darden Studio',\n * roles: ['administrator'],\n * }),\n * ]\n * ```\n */\nexport const visitorInsights = definePlugin<VisitorInsightsPluginOptions>((options) => {\n\tconst { apiBaseUrl, siteLabel, name = 'visitor-insights', title = 'Insights', roles } = options\n\n\treturn {\n\t\tname: '@liiift-studio/sanity-visitor-insights',\n\t\ttools: (prev, { currentUser }) => {\n\t\t\tif (roles && roles.length > 0) {\n\t\t\t\tconst userRoles = currentUser?.roles?.map((role) => role.name) ?? []\n\t\t\t\tconst permitted = userRoles.some((role) => roles.includes(role))\n\t\t\t\tif (!permitted) return prev\n\t\t\t}\n\n\t\t\treturn [\n\t\t\t\t...prev,\n\t\t\t\t{\n\t\t\t\t\tname,\n\t\t\t\t\ttitle,\n\t\t\t\t\ticon: ChartIcon,\n\t\t\t\t\tcomponent: VisitorInsightsTool,\n\t\t\t\t\toptions: { apiBaseUrl, siteLabel } satisfies VisitorInsightsToolProps,\n\t\t\t\t},\n\t\t\t]\n\t\t},\n\t}\n})\n\nexport default visitorInsights\n\nexport { VisitorInsightsTool, type VisitorInsightsToolProps } from './studio/VisitorInsightsTool'\nexport { useReport, type ReportState, type UseReportOptions } from './studio/useReport'\nexport { MetricFigure, ComparisonBar, NoticeList, formatCount, formatPercent } from './studio/Figure'\nexport {\n\tAcquisitionPanel,\n\tDiagnosticsPanel,\n\tJourneyPanel,\n\tMeasurementHealthPanel,\n\tTypefaceInterestPanel,\n} from './studio/panels'\n\n// Shared contract types, safe in the browser — no server code reachable from here.\nexport * from './types'\nexport { PREEXISTING, coverageForRange, type EventCutover, type Coverage } from './core/cutover'\nexport { validateSiteConfig, type SiteAnalyticsConfig } from './core/siteConfig'\nexport { resolveRange, previousRange } from './core/ranges'\n","/**\n * The Visitor Insights Studio tool.\n *\n * One range control drives every panel, implemented once here as a proper radiogroup with\n * roving tabindex — repeating a bespoke toggle per panel is how keyboard behaviour ends up\n * inconsistent between them.\n */\n\nimport React, { useCallback, useRef, useState } from 'react'\nimport { Box, Button, Card, Container, Flex, Heading, Spinner, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport type { RangeKey, ReportName } from '../types'\nimport { useReport } from './useReport'\nimport { NoticeList } from './Figure'\nimport { AcquisitionPanel, DiagnosticsPanel, JourneyPanel, MeasurementHealthPanel, TypefaceInterestPanel } from './panels'\n\n/** Range options, in the order they appear. */\nconst RANGES: Array<{ key: RangeKey; label: string }> = [\n\t{ key: 'week', label: 'Week' },\n\t{ key: 'quarter', label: 'Quarter' },\n\t{ key: 'year', label: 'Year' },\n]\n\n/** Panels, in the order they appear. */\nconst PANELS: Array<{ report: ReportName; label: string; blurb: string }> = [\n\t{ report: 'measurement-health', label: 'Measurement health', blurb: 'How much of reality each source actually sees' },\n\t{ report: 'acquisition', label: 'Acquisition', blurb: 'Where visitors come from' },\n\t{ report: 'journey', label: 'Journey', blurb: 'How far visitors get, and where they stop' },\n\t{ report: 'typeface-interest', label: 'Typeface interest', blurb: 'Viewed, tested and bought, by family' },\n\t{ report: 'diagnostics', label: 'Diagnostics', blurb: 'What to fix before trusting the numbers above' },\n]\n\n/** Props supplied by the plugin config. */\nexport interface VisitorInsightsToolProps {\n\tapiBaseUrl: string\n\tsiteLabel: string\n}\n\n/**\n * Range selector.\n * Arrow keys move between options and select as they go, which is the expected behaviour for a\n * radiogroup; Tab enters and leaves the group as a single stop.\n */\nfunction RangeSelector({ value, onChange }: { value: RangeKey; onChange: (next: RangeKey) => void }): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = RANGES.findIndex((r) => r.key === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % RANGES.length\n\t\t\tif (event.key === 'ArrowLeft' || event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + RANGES.length) % RANGES.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = RANGES.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = RANGES[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tonChange(next.key)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<Flex role=\"radiogroup\" aria-label=\"Date range\" gap={1} onKeyDown={onKeyDown}>\n\t\t\t{RANGES.map((range, index) => {\n\t\t\t\tconst selected = range.key === value\n\t\t\t\treturn (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tkey={range.key}\n\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t}}\n\t\t\t\t\t\trole=\"radio\"\n\t\t\t\t\t\taria-checked={selected}\n\t\t\t\t\t\t// Only the selected option is in the tab order, so the group is one stop.\n\t\t\t\t\t\ttabIndex={selected ? 0 : -1}\n\t\t\t\t\t\tmode={selected ? 'default' : 'bleed'}\n\t\t\t\t\t\ttone={selected ? 'primary' : 'default'}\n\t\t\t\t\t\ttext={range.label}\n\t\t\t\t\t\tonClick={() => onChange(range.key)}\n\t\t\t\t\t\tfontSize={1}\n\t\t\t\t\t\tpadding={3}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t})}\n\t\t</Flex>\n\t)\n}\n\n/**\n * Panel tabs.\n *\n * Hand-rolled rather than taken from @sanity/ui so the keyboard contract is explicit and identical\n * across the Studio versions this package supports: arrow keys move and select, Tab is a single\n * stop into the group, and each tab is wired to its panel by id.\n */\nfunction PanelTabs({ value, onChange }: { value: ReportName; onChange: (next: ReportName) => void }): React.ReactElement {\n\tconst refs = useRef<Array<HTMLButtonElement | null>>([])\n\n\tconst onKeyDown = useCallback(\n\t\t(event: React.KeyboardEvent) => {\n\t\t\tconst currentIndex = PANELS.findIndex((p) => p.report === value)\n\t\t\tlet nextIndex: number | null = null\n\n\t\t\tif (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % PANELS.length\n\t\t\tif (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + PANELS.length) % PANELS.length\n\t\t\tif (event.key === 'Home') nextIndex = 0\n\t\t\tif (event.key === 'End') nextIndex = PANELS.length - 1\n\n\t\t\tif (nextIndex === null) return\n\n\t\t\tevent.preventDefault()\n\t\t\tconst next = PANELS[nextIndex]\n\t\t\tif (!next) return\n\n\t\t\tonChange(next.report)\n\t\t\trefs.current[nextIndex]?.focus()\n\t\t},\n\t\t[value, onChange],\n\t)\n\n\treturn (\n\t\t<Flex role=\"tablist\" aria-label=\"Report\" gap={1} wrap=\"wrap\" onKeyDown={onKeyDown}>\n\t\t\t{PANELS.map((panel, index) => {\n\t\t\t\tconst selected = panel.report === value\n\t\t\t\treturn (\n\t\t\t\t\t<Button\n\t\t\t\t\t\tkey={panel.report}\n\t\t\t\t\t\tref={(el: HTMLButtonElement | null) => {\n\t\t\t\t\t\t\trefs.current[index] = el\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tid={`tab-${panel.report}`}\n\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\taria-selected={selected}\n\t\t\t\t\t\taria-controls={`panel-${panel.report}`}\n\t\t\t\t\t\ttabIndex={selected ? 0 : -1}\n\t\t\t\t\t\tmode={selected ? 'default' : 'bleed'}\n\t\t\t\t\t\ttone={selected ? 'primary' : 'default'}\n\t\t\t\t\t\ttext={panel.label}\n\t\t\t\t\t\tonClick={() => onChange(panel.report)}\n\t\t\t\t\t\tfontSize={1}\n\t\t\t\t\t\tpadding={3}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t})}\n\t\t</Flex>\n\t)\n}\n\n/** Renders one report panel, including its loading, error and empty states. */\nfunction ReportPanel({ report, apiBaseUrl, range }: { report: ReportName; apiBaseUrl: string; range: RangeKey }): React.ReactElement {\n\tconst { state, reload } = useReport<unknown>({ apiBaseUrl, report, range })\n\n\t// Announced to screen readers when figures change, so a range switch is perceivable without\n\t// re-navigating the whole panel.\n\tconst liveMessage =\n\t\tstate.status === 'loading'\n\t\t\t? 'Loading report'\n\t\t\t: state.status === 'ready'\n\t\t\t\t? `${report} updated for the selected ${range}`\n\t\t\t\t: state.status === 'error'\n\t\t\t\t\t? `Report failed: ${state.message}`\n\t\t\t\t\t: ''\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Box aria-live=\"polite\" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }}>\n\t\t\t\t{liveMessage}\n\t\t\t</Box>\n\n\t\t\t{state.status === 'loading' && (\n\t\t\t\t<Flex align=\"center\" gap={3} padding={4}>\n\t\t\t\t\t<Spinner muted />\n\t\t\t\t\t<Text size={1} muted>Loading…</Text>\n\t\t\t\t</Flex>\n\t\t\t)}\n\n\t\t\t{state.status === 'error' && (\n\t\t\t\t<Card padding={4} radius={2} tone=\"critical\" border>\n\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t<Text size={1}>{state.message}</Text>\n\t\t\t\t\t\t<Box>\n\t\t\t\t\t\t\t<Button text=\"Try again\" mode=\"ghost\" fontSize={1} onClick={reload} />\n\t\t\t\t\t\t</Box>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t{state.status === 'ready' && (\n\t\t\t\t<Stack space={4}>\n\t\t\t\t\t<NoticeList notices={state.envelope.notices} />\n\n\t\t\t\t\t{report === 'measurement-health' && <MeasurementHealthPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'acquisition' && <AcquisitionPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'journey' && <JourneyPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'typeface-interest' && <TypefaceInterestPanel data={state.envelope.data as never} />}\n\t\t\t\t\t{report === 'diagnostics' && <DiagnosticsPanel data={state.envelope.data as never} />}\n\n\t\t\t\t\t<Text size={0} muted>\n\t\t\t\t\t\t{state.envelope.range.start} to {state.envelope.range.end} ({state.envelope.range.timezone})\n\t\t\t\t\t</Text>\n\t\t\t\t</Stack>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** The tool itself. */\nexport function VisitorInsightsTool({ apiBaseUrl, siteLabel }: VisitorInsightsToolProps): React.ReactElement {\n\tconst [range, setRange] = useState<RangeKey>('week')\n\tconst [activePanel, setActivePanel] = useState<ReportName>('measurement-health')\n\n\tconst active = PANELS.find((p) => p.report === activePanel) ?? PANELS[0]\n\n\treturn (\n\t\t<Container width={4} padding={4}>\n\t\t\t<Stack space={5}>\n\t\t\t\t<Flex align=\"flex-start\" justify=\"space-between\" gap={4} wrap=\"wrap\">\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Heading size={2}>Visitor insights</Heading>\n\t\t\t\t\t\t<Text size={1} muted>{siteLabel}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t\t<RangeSelector value={range} onChange={setRange} />\n\t\t\t\t</Flex>\n\n\t\t\t\t<PanelTabs value={activePanel} onChange={setActivePanel} />\n\n\t\t\t\t<Box\n\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\tid={`panel-${activePanel}`}\n\t\t\t\t\taria-labelledby={`tab-${activePanel}`}\n\t\t\t\t\t// Focusable so a keyboard user can Tab straight from the tab into its content.\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\tstyle={{ position: 'relative' }}\n\t\t\t\t>\n\t\t\t\t\t<Stack space={4}>\n\t\t\t\t\t\t{active && <Text size={1} muted>{active.blurb}</Text>}\n\t\t\t\t\t\t<ReportPanel report={activePanel} apiBaseUrl={apiBaseUrl} range={range} />\n\t\t\t\t\t</Stack>\n\t\t\t\t</Box>\n\t\t\t</Stack>\n\t\t</Container>\n\t)\n}\n","/**\n * Data hook for the Studio panels.\n *\n * Forwards the Studio's own Sanity session token to the site's API route, which verifies it against\n * Sanity. There is no shared secret: the Studio bundle is public, so anything compiled into it is\n * extractable.\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useClient } from 'sanity'\nimport type { RangeKey, ReportEnvelope, ReportName } from '../types'\n\n/** Sanity API version this tool pins. Fixed rather than \"latest\" so behaviour cannot drift. */\nconst API_VERSION = '2024-03-01'\n\n/** Loading state for a report. */\nexport type ReportState<T> =\n\t| { status: 'idle' }\n\t| { status: 'loading' }\n\t| { status: 'ready'; envelope: ReportEnvelope<T> }\n\t| { status: 'error'; message: string }\n\n/** Options for useReport. */\nexport interface UseReportOptions {\n\t/** Base URL of the site serving the reports, e.g. `https://dardenstudio.com`. */\n\tapiBaseUrl: string\n\treport: ReportName\n\trange: RangeKey\n}\n\n/**\n * Fetch a report, re-fetching when the range changes.\n *\n * @returns the current state plus a `reload` for manual refresh\n */\nexport function useReport<T>({ apiBaseUrl, report, range }: UseReportOptions): {\n\tstate: ReportState<T>\n\treload: () => void\n} {\n\tconst client = useClient({ apiVersion: API_VERSION })\n\tconst [state, setState] = useState<ReportState<T>>({ status: 'idle' })\n\tconst [nonce, setNonce] = useState(0)\n\n\t// Lets an in-flight response from a previous range be discarded rather than overwriting a newer one.\n\tconst requestIdRef = useRef(0)\n\n\tconst reload = useCallback(() => setNonce((n) => n + 1), [])\n\n\tuseEffect(() => {\n\t\tconst requestId = requestIdRef.current + 1\n\t\trequestIdRef.current = requestId\n\n\t\tconst controller = new AbortController()\n\t\tsetState({ status: 'loading' })\n\n\t\tasync function run() {\n\t\t\t// The Studio client carries the session token under token-based auth. Under cookie-based\n\t\t\t// auth it does not, and there is no way to forward credentials the browser will not\n\t\t\t// expose — so say so plainly rather than failing with an opaque 401.\n\t\t\tconst token = client.config().token\n\t\t\tif (!token) {\n\t\t\t\tsetState({\n\t\t\t\t\tstatus: 'error',\n\t\t\t\t\tmessage: 'No Sanity session token available in this Studio. Visitor Insights needs token-based auth to call the site API.',\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst url = `${apiBaseUrl.replace(/\\/$/, '')}/api/visitor-insights/${report}?range=${range}`\n\t\t\t\tconst response = await fetch(url, {\n\t\t\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t})\n\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tconst detail = response.status === 401 ? 'Not authorised — sign in to the Studio again.' : `Request failed (${response.status})`\n\t\t\t\t\tsetState({ status: 'error', message: detail })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tconst envelope = (await response.json()) as ReportEnvelope<T>\n\t\t\t\tif (requestIdRef.current !== requestId) return\n\n\t\t\t\tsetState({ status: 'ready', envelope })\n\t\t\t} catch (e) {\n\t\t\t\tif (controller.signal.aborted || requestIdRef.current !== requestId) return\n\t\t\t\tsetState({ status: 'error', message: (e as Error).message })\n\t\t\t}\n\t\t}\n\n\t\tvoid run()\n\t\treturn () => controller.abort()\n\t}, [client, apiBaseUrl, report, range, nonce])\n\n\treturn { state, reload }\n}\n","/**\n * Shared renderers for metric values and comparison bars.\n *\n * Two rules are enforced here rather than left to each panel:\n *\n * 1. An unavailable metric renders as an em dash with a stated reason — never as \"0\". A missing\n * measurement and a measured zero mean opposite things, and must never look alike.\n * 2. Nothing is distinguished by colour alone. Bars carry a text value, a percentage and an\n * accessible label, so the figure survives greyscale, colour blindness and both Studio themes.\n */\n\nimport React from 'react'\nimport { Badge, Box, Flex, Stack, Text, Tooltip } from '@liiift-studio/sanity-ui-compat'\nimport type { MetricValue, UnavailableReason } from '../types'\n\n/** Human-readable explanation for each unavailable reason. */\nconst REASON_TEXT: Record<UnavailableReason, string> = {\n\tnot_instrumented: 'Not tracked on this site',\n\tbefore_cutover: 'Not tracked during this period',\n\tsuppressed: 'Withheld by GA4 for privacy',\n\toutage: 'Not recorded during part of this period',\n\tsource_error: 'Source did not respond',\n\tnot_applicable: 'Does not apply to this site',\n}\n\n/** Format a number with thousands separators. */\nexport function formatCount(value: number): string {\n\treturn new Intl.NumberFormat('en-GB').format(Math.round(value))\n}\n\n/** Format a 0–1 ratio as a percentage. */\nexport function formatPercent(ratio: number, digits = 0): string {\n\treturn `${(ratio * 100).toFixed(digits)}%`\n}\n\n/** Props for MetricFigure. */\nexport interface MetricFigureProps {\n\tmetric: MetricValue\n\t/** Accessible label describing what this number counts. */\n\tlabel: string\n\tsize?: number\n}\n\n/**\n * Render a metric value, handling the absent case visibly.\n * Screen readers get the reason text rather than an unexplained dash.\n */\nexport function MetricFigure({ metric, label, size = 4 }: MetricFigureProps): React.ReactElement {\n\tif (metric.status === 'unavailable') {\n\t\tconst reason = REASON_TEXT[metric.reason]\n\t\tconst detail = metric.detail ? `${reason}. ${metric.detail}` : reason\n\n\t\treturn (\n\t\t\t<Tooltip content={<Box padding={2}><Text size={1}>{detail}</Text></Box>} portal>\n\t\t\t\t<Text size={size} muted aria-label={`${label}: unavailable. ${detail}`}>\n\t\t\t\t\t<span aria-hidden=\"true\">—</span>\n\t\t\t\t</Text>\n\t\t\t</Tooltip>\n\t\t)\n\t}\n\n\tconst formatted = formatCount(metric.value)\n\n\tif (metric.status === 'partial') {\n\t\treturn (\n\t\t\t<Stack space={2}>\n\t\t\t\t<Text size={size} aria-label={`${label}: ${formatted}, partial. ${metric.note}`}>\n\t\t\t\t\t{formatted}\n\t\t\t\t</Text>\n\t\t\t\t<Badge tone=\"caution\" fontSize={0}>Partial</Badge>\n\t\t\t</Stack>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Text size={size} aria-label={`${label}: ${formatted}`}>\n\t\t\t{formatted}\n\t\t</Text>\n\t)\n}\n\n/** Props for ComparisonBar. */\nexport interface ComparisonBarProps {\n\tlabel: string\n\tmetric: MetricValue\n\t/** Largest value across the sibling bars, used to scale width. */\n\tmax: number\n\t/** Tone conveys category, but never carries meaning on its own. */\n\ttone?: 'primary' | 'positive' | 'caution' | 'default'\n}\n\n/**\n * A horizontal bar with its value printed alongside.\n *\n * Deliberately CSS rather than a charting library: these panels compare and rank a handful of\n * values, which a labelled bar does as well as a chart while avoiding a large dependency in the\n * Studio bundle and the theme-token bridging that a chart library would need for light and dark.\n */\nexport function ComparisonBar({ label, metric, max, tone = 'default' }: ComparisonBarProps): React.ReactElement {\n\tconst value = metric.status === 'unavailable' ? null : metric.value\n\tconst width = value !== null && max > 0 ? Math.max(2, (value / max) * 100) : 0\n\n\treturn (\n\t\t<Stack space={2}>\n\t\t\t<Flex align=\"center\" justify=\"space-between\" gap={3}>\n\t\t\t\t<Text size={1} weight=\"medium\">{label}</Text>\n\t\t\t\t<MetricFigure metric={metric} label={label} size={1} />\n\t\t\t</Flex>\n\n\t\t\t{value === null ? (\n\t\t\t\t// A dashed rail, not a zero-width bar: absence must not read as a measured zero.\n\t\t\t\t<Box\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: 8,\n\t\t\t\t\t\tborderRadius: 4,\n\t\t\t\t\t\tborder: '1px dashed var(--card-border-color)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t<Box\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: 8,\n\t\t\t\t\t\tborderRadius: 4,\n\t\t\t\t\t\tbackground: 'var(--card-border-color)',\n\t\t\t\t\t\toverflow: 'hidden',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Box\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\twidth: `${width}%`,\n\t\t\t\t\t\t\theight: '100%',\n\t\t\t\t\t\t\tbackground:\n\t\t\t\t\t\t\t\ttone === 'primary'\n\t\t\t\t\t\t\t\t\t? 'var(--card-focus-ring-color)'\n\t\t\t\t\t\t\t\t\t: tone === 'positive'\n\t\t\t\t\t\t\t\t\t\t? 'var(--card-badge-positive-dot-color, var(--card-focus-ring-color))'\n\t\t\t\t\t\t\t\t\t\t: tone === 'caution'\n\t\t\t\t\t\t\t\t\t\t\t? 'var(--card-badge-caution-dot-color, var(--card-muted-fg-color))'\n\t\t\t\t\t\t\t\t\t\t\t: 'var(--card-muted-fg-color)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</Box>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Props for NoticeList. */\nexport interface NoticeListProps {\n\tnotices: string[]\n}\n\n/**\n * Caveats attached to a report — sampling, processing lag, instrumentation cutovers.\n * Rendered in the panel rather than a README: a caveat nobody sees does not prevent a wrong read.\n */\nexport function NoticeList({ notices }: NoticeListProps): React.ReactElement | null {\n\tif (notices.length === 0) return null\n\n\t// A real list element, so a screen reader announces how many caveats there are. The compat\n\t// shim's Stack does not forward `as`, so the semantics are set on plain elements here.\n\treturn (\n\t\t<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 8 }}>\n\t\t\t{notices.map((notice) => (\n\t\t\t\t<li key={notice}>\n\t\t\t\t\t<Flex gap={2} align=\"flex-start\">\n\t\t\t\t\t\t<Text size={1} muted aria-hidden=\"true\">⚠</Text>\n\t\t\t\t\t\t<Text size={1} muted>{notice}</Text>\n\t\t\t\t\t</Flex>\n\t\t\t\t</li>\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n","/**\n * The four report panels.\n *\n * Every panel renders a table or labelled bars rather than a chart, and every one surfaces its own\n * caveats inline. Tables are the accessible representation as well as the visual one, so there is\n * no separate \"view as data\" toggle that could drift out of sync with what is displayed.\n */\n\nimport React from 'react'\nimport { Box, Card, Flex, Heading, Stack, Text } from '@liiift-studio/sanity-ui-compat'\nimport { ComparisonBar, MetricFigure, NoticeList, formatCount, formatPercent } from './Figure'\nimport type {\n\tAcquisitionData,\n\tCheckStatus,\n\tDiagnosticReport,\n\tJourneyData,\n\tMeasurementHealthData,\n\tTypefaceInterestData,\n} from '../reportData'\nimport type { MetricValue } from '../types'\n\n/** Largest available value across metrics, for scaling bars. */\nfunction maxOf(metrics: MetricValue[]): number {\n\treturn metrics.reduce((max, metric) => (metric.status === 'unavailable' ? max : Math.max(max, metric.value)), 0)\n}\n\n/** Shared table styling — scrolls inside its own container so the panel never scrolls sideways. */\nconst tableWrap: React.CSSProperties = { overflowX: 'auto', width: '100%' }\nconst table: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', minWidth: 420 }\nconst cell: React.CSSProperties = { padding: '8px 12px', textAlign: 'left', borderBottom: '1px solid var(--card-border-color)' }\nconst numericCell: React.CSSProperties = { ...cell, textAlign: 'right' }\n\n/**\n * Measurement Health — how much of reality each source sees.\n * Compares pageviews to pageviews. Sessions and orders sit alongside as context and are never\n * subtracted from a pageview count.\n */\nexport function MeasurementHealthPanel({ data }: { data: MeasurementHealthData }): React.ReactElement {\n\tconst pageviewMax = maxOf([data.ga4Pageviews, data.vercelPageviews])\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Stack space={3}>\n\t\t\t\t<Heading size={1}>Pageviews, source against source</Heading>\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tThe same unit on both sides. Vercel is cookieless and ungated; GA4 is consent-gated and\n\t\t\t\t\tblockable, so GA4 seeing fewer is expected.\n\t\t\t\t</Text>\n\t\t\t\t<ComparisonBar label=\"Vercel pageviews\" metric={data.vercelPageviews} max={pageviewMax} tone=\"primary\" />\n\t\t\t\t<ComparisonBar label=\"GA4 pageviews\" metric={data.ga4Pageviews} max={pageviewMax} tone=\"default\" />\n\t\t\t</Stack>\n\n\t\t\t{data.shortfallRatio !== null && (\n\t\t\t\t<Card padding={3} radius={2} tone=\"transparent\" border>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} weight=\"semibold\">\n\t\t\t\t\t\t\tGA4 shortfall: {formatPercent(Math.abs(data.shortfallRatio), 1)}\n\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t<Text size={1} muted>{data.interpretation}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t</Card>\n\t\t\t)}\n\n\t\t\t<Stack space={3}>\n\t\t\t\t<Heading size={1}>Context</Heading>\n\t\t\t\t<Text size={1} muted>\n\t\t\t\t\tDifferent units to the figures above, and to each other. Shown for scale, never differenced.\n\t\t\t\t</Text>\n\t\t\t\t<Flex gap={4} wrap=\"wrap\">\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>GA4 sessions</Text>\n\t\t\t\t\t\t<MetricFigure metric={data.ga4Sessions} label=\"GA4 sessions\" />\n\t\t\t\t\t</Stack>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>Orders</Text>\n\t\t\t\t\t\t<MetricFigure metric={data.orders} label=\"Orders\" />\n\t\t\t\t\t</Stack>\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>Consent granted</Text>\n\t\t\t\t\t\t<MetricFigure metric={data.consentRate} label=\"Consent granted, percent of sessions\" />\n\t\t\t\t\t</Stack>\n\t\t\t\t</Flex>\n\t\t\t</Stack>\n\t\t</Stack>\n\t)\n}\n\n/** Acquisition — where visitors came from, with design-industry referrers called out. */\nexport function AcquisitionPanel({ data }: { data: AcquisitionData }): React.ReactElement {\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Flex gap={4} wrap=\"wrap\">\n\t\t\t\t<Stack space={2}>\n\t\t\t\t\t<Text size={1} muted>Sessions</Text>\n\t\t\t\t\t<Text size={4}>{formatCount(data.totalSessions)}</Text>\n\t\t\t\t</Stack>\n\t\t\t\t{data.designIndustryShare !== null && (\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>From design-industry referrers</Text>\n\t\t\t\t\t\t<Text size={4}>{formatPercent(data.designIndustryShare, 1)}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t)}\n\t\t\t\t{data.unattributedShare !== null && (\n\t\t\t\t\t<Stack space={2}>\n\t\t\t\t\t\t<Text size={1} muted>Unattributed</Text>\n\t\t\t\t\t\t<Text size={4}>{formatPercent(data.unattributedShare, 1)}</Text>\n\t\t\t\t\t</Stack>\n\t\t\t\t)}\n\t\t\t</Flex>\n\n\t\t\t<Box style={tableWrap}>\n\t\t\t\t<table style={table}>\n\t\t\t\t\t<caption style={{ textAlign: 'left', paddingBottom: 8 }}>\n\t\t\t\t\t\t<Text size={1} muted>Traffic sources by sessions</Text>\n\t\t\t\t\t</caption>\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Source</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Channel</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Sessions</Text></th>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tbody>\n\t\t\t\t\t\t{data.rows.map((row) => (\n\t\t\t\t\t\t\t<tr key={`${row.source}-${row.channel}`}>\n\t\t\t\t\t\t\t\t<th scope=\"row\" style={cell}>\n\t\t\t\t\t\t\t\t\t<Flex gap={2} align=\"center\">\n\t\t\t\t\t\t\t\t\t\t<Text size={1}>{row.source}</Text>\n\t\t\t\t\t\t\t\t\t\t{row.designIndustry && <Text size={0} muted>· design industry</Text>}\n\t\t\t\t\t\t\t\t\t\t{row.unattributed && <Text size={0} muted>· unattributed</Text>}\n\t\t\t\t\t\t\t\t\t</Flex>\n\t\t\t\t\t\t\t\t</th>\n\t\t\t\t\t\t\t\t<td style={cell}><Text size={1} muted>{row.channel}</Text></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}><Text size={1}>{formatCount(row.sessions)}</Text></td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\t\t\t</Box>\n\n\t\t\t{data.rowsWithheld && (\n\t\t\t\t<NoticeList notices={['GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality.']} />\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Journey — per-step totals with adjacent drop-off, explicitly not a tracked path. */\nexport function JourneyPanel({ data }: { data: JourneyData }): React.ReactElement {\n\tconst max = maxOf(data.steps.map((step) => step.count))\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Card padding={3} radius={2} tone=\"caution\" border>\n\t\t\t\t<Text size={1}>{data.approximationNote}</Text>\n\t\t\t</Card>\n\n\t\t\t<Stack space={3}>\n\t\t\t\t{data.steps.map((step) => (\n\t\t\t\t\t<Stack space={2} key={step.key}>\n\t\t\t\t\t\t<ComparisonBar label={step.label} metric={step.count} max={max} tone=\"primary\" />\n\t\t\t\t\t\t{step.conversionFromPrevious !== null && (\n\t\t\t\t\t\t\t<Text size={0} muted>{formatPercent(step.conversionFromPrevious, 1)} of the previous measurable step</Text>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Stack>\n\t\t\t\t))}\n\t\t\t</Stack>\n\n\t\t\t{data.topExitPages.length > 0 && (\n\t\t\t\t<Stack space={3}>\n\t\t\t\t\t<Heading size={1}>Where sessions ended</Heading>\n\t\t\t\t\t<Box style={tableWrap}>\n\t\t\t\t\t\t<table style={table}>\n\t\t\t\t\t\t\t<thead>\n\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Page</Text></th>\n\t\t\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Exits</Text></th>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t{data.topExitPages.map((page) => (\n\t\t\t\t\t\t\t\t\t<tr key={page.path}>\n\t\t\t\t\t\t\t\t\t\t<th scope=\"row\" style={cell}><Text size={1}>{page.path}</Text></th>\n\t\t\t\t\t\t\t\t\t\t<td style={numericCell}><Text size={1}>{formatCount(page.exits)}</Text></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t</table>\n\t\t\t\t\t</Box>\n\t\t\t\t</Stack>\n\t\t\t)}\n\t\t</Stack>\n\t)\n}\n\n/** Typeface interest — viewed, tested and bought per family. */\nexport function TypefaceInterestPanel({ data }: { data: TypefaceInterestData }): React.ReactElement {\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Card padding={3} radius={2} tone=\"transparent\" border>\n\t\t\t\t<Text size={1} muted>{data.interpretationNote}</Text>\n\t\t\t</Card>\n\n\t\t\t<Box style={tableWrap}>\n\t\t\t\t<table style={table}>\n\t\t\t\t\t<caption style={{ textAlign: 'left', paddingBottom: 8 }}>\n\t\t\t\t\t\t<Text size={1} muted>Engagement by typeface</Text>\n\t\t\t\t\t</caption>\n\t\t\t\t\t<thead>\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<th scope=\"col\" style={cell}><Text size={1} weight=\"semibold\">Typeface</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Viewed</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Tested</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Bought</Text></th>\n\t\t\t\t\t\t\t<th scope=\"col\" style={numericCell}><Text size={1} weight=\"semibold\">Test rate</Text></th>\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t</thead>\n\t\t\t\t\t<tbody>\n\t\t\t\t\t\t{data.rows.map((row) => (\n\t\t\t\t\t\t\t<tr key={row.typeface}>\n\t\t\t\t\t\t\t\t<th scope=\"row\" style={cell}><Text size={1}>{row.typeface}</Text></th>\n\t\t\t\t\t\t\t\t<td style={numericCell}><MetricFigure metric={row.viewed} label={`${row.typeface} viewed`} size={1} /></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}><MetricFigure metric={row.tested} label={`${row.typeface} tested`} size={1} /></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}><MetricFigure metric={row.bought} label={`${row.typeface} bought`} size={1} /></td>\n\t\t\t\t\t\t\t\t<td style={numericCell}>\n\t\t\t\t\t\t\t\t\t<Text size={1} muted>{row.testRate === null ? '—' : formatPercent(row.testRate, 1)}</Text>\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</tbody>\n\t\t\t\t</table>\n\t\t\t</Box>\n\t\t</Stack>\n\t)\n}\n\n/** Sanity UI tone for each check status. Status is also always spelled out in text. */\nconst CHECK_TONE: Record<CheckStatus, 'positive' | 'caution' | 'critical' | 'default'> = {\n\tpass: 'positive',\n\twarn: 'caution',\n\tfail: 'critical',\n\tskipped: 'default',\n}\n\n/** Word shown alongside the tone, so status never depends on colour alone. */\nconst CHECK_WORD: Record<CheckStatus, string> = {\n\tpass: 'Pass',\n\twarn: 'Check',\n\tfail: 'Fail',\n\tskipped: 'Skipped',\n}\n\n/**\n * Diagnostics — what to fix before trusting anything else here.\n *\n * Deliberately the panel that still works with nothing configured: on a site without credentials\n * it is the only one that can say something useful, and it is the first thing worth opening once\n * credentials land.\n */\nexport function DiagnosticsPanel({ data }: { data: DiagnosticReport }): React.ReactElement {\n\tconst failing = data.checks.filter((c) => c.status === 'fail').length\n\tconst warning = data.checks.filter((c) => c.status === 'warn').length\n\n\tconst summary =\n\t\tdata.verdict === 'pass'\n\t\t\t? 'Everything checked out. The figures in the other panels can be taken at face value.'\n\t\t\t: `${failing} failing, ${warning} worth a look. Panels depending on these will be wrong or incomplete until they are resolved.`\n\n\treturn (\n\t\t<Stack space={4}>\n\t\t\t<Card padding={3} radius={2} tone={CHECK_TONE[data.verdict]} border>\n\t\t\t\t<Text size={1}>{summary}</Text>\n\t\t\t</Card>\n\n\t\t\t<Stack space={3}>\n\t\t\t\t{data.checks.map((item) => (\n\t\t\t\t\t<Card key={item.id} padding={3} radius={2} tone=\"transparent\" border>\n\t\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t\t<Flex align=\"center\" justify=\"space-between\" gap={3}>\n\t\t\t\t\t\t\t\t<Text size={1} weight=\"semibold\">{item.label}</Text>\n\t\t\t\t\t\t\t\t<Card padding={2} radius={2} tone={CHECK_TONE[item.status]}>\n\t\t\t\t\t\t\t\t\t<Text size={0} weight=\"medium\">{CHECK_WORD[item.status]}</Text>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t</Flex>\n\t\t\t\t\t\t\t<Text size={1} muted>{item.detail}</Text>\n\t\t\t\t\t\t\t{item.remedy && <Text size={1}>{item.remedy}</Text>}\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t</Card>\n\t\t\t\t))}\n\t\t\t</Stack>\n\t\t</Stack>\n\t)\n}\n","/** Shared contract types for the Studio plugin and the API-route handler it calls. */\n\n// ---------------------------------------------------------------------------\n// Metric values\n// ---------------------------------------------------------------------------\n\n/**\n * Why a metric has no usable number. Kept distinct rather than collapsed into one flag, because\n * the UI must say different things: \"this site never tracked it\" is permanent, \"not yet at this\n * date\" resolves as time passes, and \"GA4 withheld it\" means the number exists but is hidden.\n */\nexport type UnavailableReason =\n\t/** The event has never been instrumented on this site. Permanent for historical ranges. */\n\t| 'not_instrumented'\n\t/** Instrumented, but the requested range predates the cutover date. */\n\t| 'before_cutover'\n\t/** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */\n\t| 'suppressed'\n\t/** The event exists in the code but stopped reaching GA4 for this period. Not a real zero. */\n\t| 'outage'\n\t/** The upstream source errored or timed out. Retryable, unlike the others. */\n\t| 'source_error'\n\t/** The metric is not defined for this site at all (e.g. a flow that does not exist there). */\n\t| 'not_applicable'\n\n/**\n * A metric that may legitimately have no value.\n *\n * The whole point of this union is that there is no way to read a number without first handling\n * the absent case — a missing metric can never silently coerce to 0 and be charted as a real\n * trough. `partial` carries a number that is only valid for part of the requested range.\n */\nexport type MetricValue =\n\t| { status: 'ok'; value: number }\n\t| { status: 'partial'; value: number; coveredFrom: string; note: string }\n\t| { status: 'unavailable'; reason: UnavailableReason; detail?: string }\n\n/** Construct an available metric. */\nexport function ok(value: number): MetricValue {\n\treturn { status: 'ok', value }\n}\n\n/** Construct an unavailable metric. */\nexport function unavailable(reason: UnavailableReason, detail?: string): MetricValue {\n\treturn { status: 'unavailable', reason, detail }\n}\n\n/**\n * Construct a metric covering only part of the requested range.\n *\n * @param value - the number for the covered portion\n * @param coveredFrom - ISO date from which the number is valid\n * @param note - short human-readable reason, shown next to the figure\n */\nexport function partial(value: number, coveredFrom: string, note: string): MetricValue {\n\treturn { status: 'partial', value, coveredFrom, note }\n}\n\n/**\n * Read a metric's number, or `null` when there isn't a usable one.\n * Use at render time so the absent case has to be handled explicitly.\n */\nexport function valueOrNull(metric: MetricValue): number | null {\n\treturn metric.status === 'unavailable' ? null : metric.value\n}\n\n// ---------------------------------------------------------------------------\n// Date ranges\n// ---------------------------------------------------------------------------\n\n/** The range granularities offered in the UI. */\nexport type RangeKey = 'week' | 'quarter' | 'year'\n\n/**\n * A resolved date range. Both bounds are inclusive ISO `YYYY-MM-DD` dates in `timezone`.\n *\n * `timezone` is carried explicitly because GA4 buckets by the property's configured timezone,\n * Vercel reports in UTC, and Sanity `_createdAt` is UTC — comparing them without a single declared\n * anchor silently shifts events across day and week boundaries.\n */\nexport interface DateRange {\n\tkey: RangeKey\n\tstart: string\n\tend: string\n\ttimezone: string\n}\n\n// ---------------------------------------------------------------------------\n// Reports\n// ---------------------------------------------------------------------------\n\n/** The report names this package serves. Used as a strict allow-list, never as dynamic dispatch. */\nexport const REPORT_NAMES = [\n\t'measurement-health',\n\t'acquisition',\n\t'journey',\n\t'typeface-interest',\n\t'diagnostics',\n] as const\n\nexport type ReportName = (typeof REPORT_NAMES)[number]\n\n/** Narrow an untrusted string to a known report name. */\nexport function isReportName(value: unknown): value is ReportName {\n\treturn typeof value === 'string' && (REPORT_NAMES as readonly string[]).includes(value)\n}\n\n/** Which upstreams a report consulted, and how each fared. */\nexport type SourceStatus =\n\t| { status: 'ok' }\n\t| { status: 'unconfigured' }\n\t| { status: 'error'; message: string }\n\n/** Upstream data sources this package can read. */\nexport type SourceName = 'ga4' | 'vercel' | 'sanity'\n\n/**\n * Envelope around every report response.\n *\n * `sources` is mandatory rather than optional because partial failure is the normal case here,\n * not the exception: a panel comparing three sources must be able to distinguish \"this source\n * reported zero\" from \"this source never answered\", which a bare payload cannot express.\n */\nexport interface ReportEnvelope<T> {\n\treport: ReportName\n\trange: DateRange\n\t/** Per-source outcome. A report may succeed overall while one source is unconfigured. */\n\tsources: Partial<Record<SourceName, SourceStatus>>\n\t/** Caveats to surface in the UI, e.g. GA4 sampling or an instrumentation cutover in range. */\n\tnotices: string[]\n\tdata: T\n}\n\n/** Error shape returned by the handler. Never leaks upstream error text to the browser. */\nexport interface ReportError {\n\terror: string\n\treport?: string\n}\n","/**\n * Event instrumentation history — when an event started, and when it stopped.\n *\n * GA4 cannot backfill events: data from before an event was instrumented does not exist and never\n * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn\n * across it reads as a change in visitor behaviour when it is really a change in what was counted.\n *\n * Outages are the same problem arriving from the other direction, and are easy to miss because the\n * event still exists in the code. Darden is the worked example: its ecommerce events fired normally\n * until a script-loading change on 2025-11-20 stopped them reaching GA4 for nine months. Every one\n * of those days returns a real, honest zero from the API — a zero that means \"not recorded\", not\n * \"no sales\". Without an outage recorded here, a yearly funnel would render that as a collapse in\n * trade rather than as a gap in measurement.\n *\n * This lives in the package rather than in each site repo so all three foundries share one model\n * and one set of semantics, and so the Studio panel and the server handler agree by construction.\n */\n\nimport type { DateRange, MetricValue } from '../types'\nimport { unavailable, partial } from '../types'\n\n/** An event that predates this work — long-running, with no start discontinuity to mark. */\nexport const PREEXISTING = 'preexisting' as const\n\n/**\n * A period during which an event stopped reaching GA4 despite still existing in the code.\n * `until: null` means it is still broken as of now.\n */\nexport interface EventOutage {\n\t/** First ISO date with no data. */\n\tstart: string\n\t/** ISO date it resumed, or null if unresolved. */\n\tuntil: string | null\n\t/** Short human-readable cause, shown in the UI next to the affected figure. */\n\treason?: string\n}\n\n/** An event with a start date and, optionally, periods where it stopped firing. */\nexport interface EventHistory {\n\t/** When it began firing: `PREEXISTING`, or an ISO `YYYY-MM-DD` deploy date. */\n\tfrom: typeof PREEXISTING | string\n\t/** Periods where it stopped. Order does not matter. */\n\toutages?: EventOutage[]\n}\n\n/**\n * When an event fired on a site.\n * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.\n * An ISO `YYYY-MM-DD` string = the deploy date. An `EventHistory` adds outages.\n */\nexport type EventCutover = typeof PREEXISTING | string | null | EventHistory\n\n/** How completely an event covers a requested range. */\nexport type Coverage =\n\t| { status: 'full' }\n\t| {\n\t\t\tstatus: 'partial'\n\t\t\treason: 'spans_cutover' | 'spans_outage'\n\t\t\t/** Present when the range starts before the event was instrumented. */\n\t\t\tcutover?: string\n\t\t\t/** Outages overlapping the range. Empty for a pure cutover overlap. */\n\t\t\toutages: EventOutage[]\n\t }\n\t| {\n\t\t\tstatus: 'none'\n\t\t\treason: 'not_instrumented' | 'before_cutover' | 'unknown_event' | 'outage'\n\t\t\tcutover?: string\n\t\t\t/** Present when the whole range fell inside an outage. */\n\t\t\toutages?: EventOutage[]\n\t }\n\n/** Normalise the shorthand forms into an EventHistory, or null when not instrumented. */\nfunction toHistory(cutover: EventCutover | undefined): EventHistory | null {\n\tif (cutover === null || cutover === undefined) return null\n\tif (typeof cutover === 'string') return { from: cutover }\n\tif (!cutover.from) return null\n\treturn cutover\n}\n\n/** Whether two inclusive date ranges overlap at all. */\nfunction overlaps(aStart: string, aEnd: string | null, bStart: string, bEnd: string): boolean {\n\t// A null end means \"ongoing\", so it extends past any range end.\n\treturn aStart <= bEnd && (aEnd === null || aEnd >= bStart)\n}\n\n/**\n * Determine how well an event covers a date range.\n *\n * @param cutovers - the site's event history map\n * @param eventName - GA4 event name to look up\n * @param range - the requested range\n */\nexport function coverageForRange(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventName: string,\n\trange: Pick<DateRange, 'start' | 'end'>,\n): Coverage {\n\tif (!(eventName in cutovers)) return { status: 'none', reason: 'unknown_event' }\n\n\tconst history = toHistory(cutovers[eventName])\n\tif (!history) return { status: 'none', reason: 'not_instrumented' }\n\n\t// --- start date -----------------------------------------------------------\n\tlet cutover: string | undefined\n\tlet startsBeforeCutover = false\n\n\tif (history.from !== PREEXISTING) {\n\t\tconst cutoverTime = Date.parse(history.from)\n\t\t// An unparseable date is a config error; treat it as unknown rather than silently trusting it.\n\t\tif (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }\n\n\t\tcutover = history.from\n\t\tif (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }\n\t\tstartsBeforeCutover = Date.parse(range.start) < cutoverTime\n\t}\n\n\t// --- outages --------------------------------------------------------------\n\tconst hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end))\n\n\t// A range sitting entirely inside one outage has no usable data at all.\n\tconst swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end))\n\tif (swallowed) return { status: 'none', reason: 'outage', outages: [swallowed], cutover }\n\n\tif (hit.length > 0) return { status: 'partial', reason: 'spans_outage', outages: hit, cutover }\n\tif (startsBeforeCutover) return { status: 'partial', reason: 'spans_cutover', outages: [], cutover }\n\n\treturn { status: 'full' }\n}\n\n/**\n * Wrap a raw count in a MetricValue that reflects the event's coverage of the range.\n *\n * Call this instead of returning a bare number, so a value that only covers part of the range\n * carries that fact with it all the way to the renderer.\n *\n * @param count - the number GA4 returned, or null if the query could not run\n * @param coverage - result of coverageForRange for the same event and range\n */\nexport function applyCoverage(count: number | null, coverage: Coverage): MetricValue {\n\tif (coverage.status === 'none') {\n\t\tif (coverage.reason === 'outage') {\n\t\t\tconst outage = coverage.outages?.[0]\n\t\t\treturn unavailable('outage', outage ? describeOutage(outage) : undefined)\n\t\t}\n\t\treturn unavailable(\n\t\t\tcoverage.reason === 'before_cutover' ? 'before_cutover' : 'not_instrumented',\n\t\t\tcoverage.cutover ? `Instrumented from ${coverage.cutover}` : undefined,\n\t\t)\n\t}\n\n\tif (count === null) return unavailable('source_error')\n\n\tif (coverage.status === 'partial') {\n\t\tif (coverage.reason === 'spans_outage') {\n\t\t\tconst outage = coverage.outages[0]\n\t\t\t// coveredFrom is the outage end where known, since that is when data resumes.\n\t\t\tconst from = outage?.until ?? outage?.start ?? coverage.cutover ?? ''\n\t\t\treturn partial(count, from, `Undercounted: ${describeOutage(outage)}`)\n\t\t}\n\n\t\tconst from = coverage.cutover ?? ''\n\t\treturn partial(count, from, `Only counted from ${from}, when this event was added`)\n\t}\n\n\treturn { status: 'ok', value: count }\n}\n\n/** One-line description of an outage, for display beside an affected figure. */\nexport function describeOutage(outage: EventOutage | undefined): string {\n\tif (!outage) return 'an outage affected part of this range'\n\tconst period = outage.until ? `${outage.start} to ${outage.until}` : `${outage.start} onwards`\n\treturn outage.reason ? `not recorded ${period} (${outage.reason})` : `not recorded ${period}`\n}\n\n/**\n * Human-readable notices for every event in a range that is not fully covered.\n * Surfaced in the report envelope so the panel can show caveats without recomputing them.\n */\nexport function coverageNotices(\n\tcutovers: Readonly<Record<string, EventCutover>>,\n\teventNames: readonly string[],\n\trange: Pick<DateRange, 'start' | 'end'>,\n): string[] {\n\tconst notices: string[] = []\n\n\tfor (const name of eventNames) {\n\t\tconst coverage = coverageForRange(cutovers, name, range)\n\n\t\tif (coverage.status === 'partial' && coverage.reason === 'spans_outage') {\n\t\t\tnotices.push(`${name} was ${describeOutage(coverage.outages[0])}, so figures for this range are too low — not a real decline.`)\n\t\t} else if (coverage.status === 'partial') {\n\t\t\tnotices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'outage') {\n\t\t\tnotices.push(`${name} was ${describeOutage(coverage.outages?.[0])}, covering this whole range. The figure is unavailable, not zero.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'not_instrumented') {\n\t\t\tnotices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`)\n\t\t} else if (coverage.status === 'none' && coverage.reason === 'before_cutover') {\n\t\t\tnotices.push(`${name} did not exist during this range; it was instrumented on ${coverage.cutover}.`)\n\t\t}\n\t}\n\n\treturn notices\n}\n","/**\n * Per-site adapter configuration.\n *\n * The three foundries differ in ways that cannot be abstracted away: different GA4 event coverage,\n * different order schemas, different flows that exist on one site and not another. Rather than\n * branching on a site id inside the reports — which would mean editing this package to onboard a\n * fourth foundry — each site describes itself through this config and the reports stay generic.\n */\n\nimport type { EventCutover } from './cutover'\n\n/** GA4 connection for one site. */\nexport interface Ga4Config {\n\t/** Numeric GA4 property id. NOT the `G-XXXXXXX` measurement id, which is the client-side one. */\n\tpropertyId: string\n\t/** IANA timezone the property is configured with. Every range is anchored to this. */\n\ttimezone: string\n}\n\n/** Vercel Web Analytics connection for one site. */\nexport interface VercelConfig {\n\tprojectId: string\n\tteamId?: string\n}\n\n/** Where to count orders, and what they are called on this site. */\nexport interface OrdersConfig {\n\t/** Document type holding orders. */\n\tdocumentType: string\n\t/**\n\t * Field holding the typeface references on an order, if the site has one.\n\t * Null where orders do not resolve to typefaces in a usable way.\n\t */\n\ttypefacesField: string | null\n\t/**\n\t * GROQ filter appended to exclude non-typeface orders, e.g. merch-only orders on Darden.\n\t * Merch inflates a family's apparent purchase count if not excluded.\n\t */\n\texcludeFilter?: string\n}\n\n/** One site's complete description of itself. */\nexport interface SiteAnalyticsConfig {\n\t/** Stable machine id, e.g. `darden`. */\n\tsiteId: string\n\t/** Human label shown in the Studio UI. */\n\tlabel: string\n\t/** Null when GA4 is not wired up for this site — reports degrade rather than fail. */\n\tga4: Ga4Config | null\n\t/** Null when Vercel Web Analytics is unavailable, e.g. on a plan tier without API access. */\n\tvercel: VercelConfig | null\n\torders: OrdersConfig\n\t/**\n\t * When each GA4 event began firing here. Events absent from this map report as `unknown_event`,\n\t * which is the honest answer for a flow the site does not have — distinct from `not_instrumented`,\n\t * which implies it is merely pending.\n\t */\n\teventCutovers: Record<string, EventCutover>\n\t/** Origins allowed to call this site's handler cross-origin, e.g. a separately deployed Studio. */\n\tallowedStudioOrigins?: string[]\n}\n\n/** A problem found while validating a site config. */\nexport interface ConfigProblem {\n\tfield: string\n\tmessage: string\n}\n\n/**\n * Validate a site config at runtime.\n *\n * Compile-time types do not survive into a consuming site's `sanity.config.js`, which is plain\n * JavaScript — so a typo'd property id or a measurement id pasted where a numeric property id\n * belongs would otherwise surface as an empty chart rather than an error.\n *\n * @param config - the candidate config\n * @returns the problems found; an empty array means the config is usable\n */\nexport function validateSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): ConfigProblem[] {\n\tconst problems: ConfigProblem[] = []\n\n\tif (!config) {\n\t\treturn [{ field: 'config', message: 'No site config supplied' }]\n\t}\n\n\tif (!config.siteId) problems.push({ field: 'siteId', message: 'Required' })\n\tif (!config.label) problems.push({ field: 'label', message: 'Required' })\n\n\tif (config.ga4) {\n\t\tconst { propertyId, timezone } = config.ga4\n\t\tif (!propertyId) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Required when ga4 is configured' })\n\t\t} else if (propertyId.startsWith('G-')) {\n\t\t\tproblems.push({\n\t\t\t\tfield: 'ga4.propertyId',\n\t\t\t\tmessage: `Looks like a measurement id (\"${propertyId}\"). The Data API needs the numeric property id from GA4 Admin instead.`,\n\t\t\t})\n\t\t} else if (!/^\\d+$/.test(propertyId)) {\n\t\t\tproblems.push({ field: 'ga4.propertyId', message: 'Must be the numeric GA4 property id' })\n\t\t}\n\n\t\tif (!timezone) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: 'Required — ranges are anchored to it' })\n\t\t} else if (!isValidTimeZone(timezone)) {\n\t\t\tproblems.push({ field: 'ga4.timezone', message: `Not a recognised IANA timezone: ${timezone}` })\n\t\t}\n\t}\n\n\tif (config.vercel && !config.vercel.projectId) {\n\t\tproblems.push({ field: 'vercel.projectId', message: 'Required when vercel is configured' })\n\t}\n\n\tif (!config.orders?.documentType) {\n\t\tproblems.push({ field: 'orders.documentType', message: 'Required' })\n\t}\n\n\tif (!config.eventCutovers) {\n\t\tproblems.push({ field: 'eventCutovers', message: 'Required — omit events the site does not have' })\n\t}\n\n\tfor (const origin of config.allowedStudioOrigins ?? []) {\n\t\tif (!/^https?:\\/\\//.test(origin)) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must be a full origin including scheme: ${origin}` })\n\t\t}\n\t\tif (origin.endsWith('/')) {\n\t\t\tproblems.push({ field: 'allowedStudioOrigins', message: `Must not have a trailing slash: ${origin}` })\n\t\t}\n\t}\n\n\treturn problems\n}\n\n/** Whether a string is an IANA timezone this runtime recognises. */\nexport function isValidTimeZone(timeZone: string): boolean {\n\ttry {\n\t\tnew Intl.DateTimeFormat('en-CA', { timeZone })\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/** Throw if a config is unusable, naming every problem at once rather than one per run. */\nexport function assertValidSiteConfig(config: Partial<SiteAnalyticsConfig> | undefined): asserts config is SiteAnalyticsConfig {\n\tconst problems = validateSiteConfig(config)\n\tif (problems.length > 0) {\n\t\tconst detail = problems.map((p) => ` ${p.field}: ${p.message}`).join('\\n')\n\t\tthrow new Error(`Invalid visitor-insights site config:\\n${detail}`)\n\t}\n}\n","/**\n * Date-range resolution.\n *\n * Every range is resolved against one declared timezone — the GA4 property's — because the three\n * sources bucket time differently: GA4 by the property timezone, Vercel by UTC, Sanity `_createdAt`\n * by UTC. Without a single anchor, an order placed near midnight lands in different weeks depending\n * on which source you ask, which corrupts exactly the cross-source comparison this tool exists for.\n */\n\nimport type { DateRange, RangeKey } from '../types'\n\n/** GA4 does not finalise the most recent days. Figures inside this window may still rise. */\nexport const GA4_PROCESSING_LAG_DAYS = 2\n\n/** Format a Date as `YYYY-MM-DD` in the given IANA timezone. */\nexport function formatInTimeZone(date: Date, timeZone: string): string {\n\t// en-CA gives ISO-ordered parts, which is what we want to reassemble.\n\tconst parts = new Intl.DateTimeFormat('en-CA', {\n\t\ttimeZone,\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t}).formatToParts(date)\n\n\tconst get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''\n\treturn `${get('year')}-${get('month')}-${get('day')}`\n}\n\n/** Shift an ISO `YYYY-MM-DD` date by a whole number of days. */\nexport function shiftDays(isoDate: string, days: number): string {\n\tconst time = Date.parse(`${isoDate}T00:00:00Z`)\n\tif (Number.isNaN(time)) throw new Error(`Invalid ISO date: ${isoDate}`)\n\treturn new Date(time + days * 86_400_000).toISOString().slice(0, 10)\n}\n\n/** Inclusive whole days between two ISO dates. */\nexport function daysBetween(start: string, end: string): number {\n\treturn Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000) + 1\n}\n\n/** How many days each range key spans. */\nconst RANGE_DAYS: Record<RangeKey, number> = {\n\tweek: 7,\n\tquarter: 91,\n\tyear: 365,\n}\n\n/**\n * Resolve a range key into concrete dates, anchored to `timezone` and ending today.\n *\n * @param key - week, quarter or year\n * @param timezone - IANA timezone of the GA4 property this range will be queried against\n * @param now - injectable clock, so tests are deterministic\n */\nexport function resolveRange(key: RangeKey, timezone: string, now: Date = new Date()): DateRange {\n\tconst end = formatInTimeZone(now, timezone)\n\tconst start = shiftDays(end, -(RANGE_DAYS[key] - 1))\n\treturn { key, start, end, timezone }\n}\n\n/**\n * The equivalent range immediately before `range`, for period-over-period comparison.\n *\n * A bare count answers \"how many\", which on its own is not actionable; the comparison is what\n * tells someone whether to do anything.\n */\nexport function previousRange(range: DateRange): DateRange {\n\tconst span = daysBetween(range.start, range.end)\n\treturn {\n\t\tkey: range.key,\n\t\tstart: shiftDays(range.start, -span),\n\t\tend: shiftDays(range.end, -span),\n\t\ttimezone: range.timezone,\n\t}\n}\n\n/**\n * The trailing dates within a range whose GA4 figures are not yet settled.\n * Returns an empty array when the range ends before the lag window.\n */\nexport function provisionalDates(range: DateRange, now: Date = new Date()): string[] {\n\tconst today = formatInTimeZone(now, range.timezone)\n\tconst dates: string[] = []\n\n\tfor (let i = 0; i < GA4_PROCESSING_LAG_DAYS; i += 1) {\n\t\tconst date = shiftDays(today, -i)\n\t\tif (date >= range.start && date <= range.end) dates.push(date)\n\t}\n\n\treturn dates.sort()\n}\n\n/**\n * Notice text when a range includes days GA4 has not finished processing, or `null` when it does not.\n * Without this a Monday-morning glance at \"this week\" reads the trailing dip as a real drop.\n */\nexport function provisionalNotice(range: DateRange, now: Date = new Date()): string | null {\n\tconst dates = provisionalDates(range, now)\n\tif (dates.length === 0) return null\n\n\tconst from = dates[0]\n\treturn `GA4 has not finished processing ${from} onwards; those figures are provisional and will rise.`\n}\n"],"mappings":"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","names":["import_sanity","import_react","import_sanity_ui_compat","import_sanity_ui_compat","import_jsx_runtime","import_jsx_runtime"]}
package/dist/index.mjs CHANGED
@@ -63,6 +63,7 @@ var REASON_TEXT = {
63
63
  not_instrumented: "Not tracked on this site",
64
64
  before_cutover: "Not tracked during this period",
65
65
  suppressed: "Withheld by GA4 for privacy",
66
+ outage: "Not recorded during part of this period",
66
67
  source_error: "Source did not respond",
67
68
  not_applicable: "Does not apply to this site"
68
69
  };
@@ -484,15 +485,33 @@ function isReportName(value) {
484
485
 
485
486
  // src/core/cutover.ts
486
487
  var PREEXISTING = "preexisting";
488
+ function toHistory(cutover) {
489
+ if (cutover === null || cutover === void 0) return null;
490
+ if (typeof cutover === "string") return { from: cutover };
491
+ if (!cutover.from) return null;
492
+ return cutover;
493
+ }
494
+ function overlaps(aStart, aEnd, bStart, bEnd) {
495
+ return aStart <= bEnd && (aEnd === null || aEnd >= bStart);
496
+ }
487
497
  function coverageForRange(cutovers, eventName, range) {
488
498
  if (!(eventName in cutovers)) return { status: "none", reason: "unknown_event" };
489
- const cutover = cutovers[eventName];
490
- if (cutover === null || cutover === void 0) return { status: "none", reason: "not_instrumented" };
491
- if (cutover === PREEXISTING) return { status: "full" };
492
- const cutoverTime = Date.parse(cutover);
493
- if (Number.isNaN(cutoverTime)) return { status: "none", reason: "unknown_event" };
494
- if (Date.parse(range.end) < cutoverTime) return { status: "none", reason: "before_cutover", cutover };
495
- if (Date.parse(range.start) < cutoverTime) return { status: "partial", cutover };
499
+ const history = toHistory(cutovers[eventName]);
500
+ if (!history) return { status: "none", reason: "not_instrumented" };
501
+ let cutover;
502
+ let startsBeforeCutover = false;
503
+ if (history.from !== PREEXISTING) {
504
+ const cutoverTime = Date.parse(history.from);
505
+ if (Number.isNaN(cutoverTime)) return { status: "none", reason: "unknown_event" };
506
+ cutover = history.from;
507
+ if (Date.parse(range.end) < cutoverTime) return { status: "none", reason: "before_cutover", cutover };
508
+ startsBeforeCutover = Date.parse(range.start) < cutoverTime;
509
+ }
510
+ const hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end));
511
+ const swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end));
512
+ if (swallowed) return { status: "none", reason: "outage", outages: [swallowed], cutover };
513
+ if (hit.length > 0) return { status: "partial", reason: "spans_outage", outages: hit, cutover };
514
+ if (startsBeforeCutover) return { status: "partial", reason: "spans_cutover", outages: [], cutover };
496
515
  return { status: "full" };
497
516
  }
498
517