@liiift-studio/sanity-visitor-insights 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.d.mts +3 -2
- package/dist/server.d.ts +3 -2
- package/dist/server.js +6 -0
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +3 -0
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{vercel-D19ArNAY.d.mts → vercel-BpDH6Vva.d.mts} +40 -1
- package/dist/{vercel-D19ArNAY.d.ts → vercel-BpDH6Vva.d.ts} +40 -1
- package/package.json +2 -2
- package/src/server.ts +6 -0
package/dist/server.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-DSFapooC.mjs';
|
|
2
2
|
export { A as AcquisitionData, m as CheckStatus, n as ConfigProblem, C as Coverage, d as DateRange, q as DiagnosticCheck, E as EventCutover, s as ExitPage, G as GA4_PROCESSING_LAG_DAYS, t as Ga4Config, J as JourneyData, w as JourneyStep, c as MeasurementHealthData, M as MetricValue, O as OrdersConfig, P as PREEXISTING, e as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, f as ReportError, a as ReportName, g as SourceName, x as SourceRow, h as SourceStatus, T as TypefaceInterestData, y as TypefaceInterestRow, U as UnavailableReason, V as VercelConfig, z as applyCoverage, B as assertValidSiteConfig, i as coverageForRange, F as coverageNotices, H as daysBetween, I as formatInTimeZone, j as isReportName, K as isValidTimeZone, o as ok, p as partial, k as previousRange, L as provisionalDates, N as provisionalNotice, r as resolveRange, Q as shiftDays, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-DSFapooC.mjs';
|
|
3
|
-
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-
|
|
3
|
+
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-BpDH6Vva.mjs';
|
|
4
|
+
export { a as Ga4Report, b as Ga4ReportRequest, c as ServiceAccountKey, d as VercelPageviews, e as createGa4Client, f as createVercelClient, p as parseServiceAccountKey } from './vercel-BpDH6Vva.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Request authentication and CORS for the report handler.
|
|
@@ -208,4 +209,4 @@ interface DiagnosticsInput {
|
|
|
208
209
|
*/
|
|
209
210
|
declare function runDiagnostics(input: DiagnosticsInput): Promise<DiagnosticReport>;
|
|
210
211
|
|
|
211
|
-
export { DESIGN_INDUSTRY_SOURCES, DiagnosticReport, type DiagnosticsInput, ENV_VARS, type HandlerOptions, type HandlerRequest, type HandlerResponse, JOURNEY_STEPS, SanityQueryClient, SiteAnalyticsConfig, type StudioUser, applyCors, clearCache, createVisitorInsightsHandler, requireStudioUser, runDiagnostics, verifyStudioRequest };
|
|
212
|
+
export { DESIGN_INDUSTRY_SOURCES, DiagnosticReport, type DiagnosticsInput, ENV_VARS, Ga4Client, type HandlerOptions, type HandlerRequest, type HandlerResponse, JOURNEY_STEPS, SanityQueryClient, SiteAnalyticsConfig, type StudioUser, VercelClient, applyCors, clearCache, createVisitorInsightsHandler, requireStudioUser, runDiagnostics, verifyStudioRequest };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-DSFapooC.js';
|
|
2
2
|
export { A as AcquisitionData, m as CheckStatus, n as ConfigProblem, C as Coverage, d as DateRange, q as DiagnosticCheck, E as EventCutover, s as ExitPage, G as GA4_PROCESSING_LAG_DAYS, t as Ga4Config, J as JourneyData, w as JourneyStep, c as MeasurementHealthData, M as MetricValue, O as OrdersConfig, P as PREEXISTING, e as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, f as ReportError, a as ReportName, g as SourceName, x as SourceRow, h as SourceStatus, T as TypefaceInterestData, y as TypefaceInterestRow, U as UnavailableReason, V as VercelConfig, z as applyCoverage, B as assertValidSiteConfig, i as coverageForRange, F as coverageNotices, H as daysBetween, I as formatInTimeZone, j as isReportName, K as isValidTimeZone, o as ok, p as partial, k as previousRange, L as provisionalDates, N as provisionalNotice, r as resolveRange, Q as shiftDays, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-DSFapooC.js';
|
|
3
|
-
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-
|
|
3
|
+
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-BpDH6Vva.js';
|
|
4
|
+
export { a as Ga4Report, b as Ga4ReportRequest, c as ServiceAccountKey, d as VercelPageviews, e as createGa4Client, f as createVercelClient, p as parseServiceAccountKey } from './vercel-BpDH6Vva.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Request authentication and CORS for the report handler.
|
|
@@ -208,4 +209,4 @@ interface DiagnosticsInput {
|
|
|
208
209
|
*/
|
|
209
210
|
declare function runDiagnostics(input: DiagnosticsInput): Promise<DiagnosticReport>;
|
|
210
211
|
|
|
211
|
-
export { DESIGN_INDUSTRY_SOURCES, DiagnosticReport, type DiagnosticsInput, ENV_VARS, type HandlerOptions, type HandlerRequest, type HandlerResponse, JOURNEY_STEPS, SanityQueryClient, SiteAnalyticsConfig, type StudioUser, applyCors, clearCache, createVisitorInsightsHandler, requireStudioUser, runDiagnostics, verifyStudioRequest };
|
|
212
|
+
export { DESIGN_INDUSTRY_SOURCES, DiagnosticReport, type DiagnosticsInput, ENV_VARS, Ga4Client, type HandlerOptions, type HandlerRequest, type HandlerResponse, JOURNEY_STEPS, SanityQueryClient, SiteAnalyticsConfig, type StudioUser, VercelClient, applyCors, clearCache, createVisitorInsightsHandler, requireStudioUser, runDiagnostics, verifyStudioRequest };
|
package/dist/server.js
CHANGED
|
@@ -32,12 +32,15 @@ __export(server_exports, {
|
|
|
32
32
|
clearCache: () => clearCache,
|
|
33
33
|
coverageForRange: () => coverageForRange,
|
|
34
34
|
coverageNotices: () => coverageNotices,
|
|
35
|
+
createGa4Client: () => createGa4Client,
|
|
36
|
+
createVercelClient: () => createVercelClient,
|
|
35
37
|
createVisitorInsightsHandler: () => createVisitorInsightsHandler,
|
|
36
38
|
daysBetween: () => daysBetween,
|
|
37
39
|
formatInTimeZone: () => formatInTimeZone,
|
|
38
40
|
isReportName: () => isReportName,
|
|
39
41
|
isValidTimeZone: () => isValidTimeZone,
|
|
40
42
|
ok: () => ok,
|
|
43
|
+
parseServiceAccountKey: () => parseServiceAccountKey,
|
|
41
44
|
partial: () => partial,
|
|
42
45
|
previousRange: () => previousRange,
|
|
43
46
|
provisionalDates: () => provisionalDates,
|
|
@@ -1193,12 +1196,15 @@ async function runReport(report, ctx) {
|
|
|
1193
1196
|
clearCache,
|
|
1194
1197
|
coverageForRange,
|
|
1195
1198
|
coverageNotices,
|
|
1199
|
+
createGa4Client,
|
|
1200
|
+
createVercelClient,
|
|
1196
1201
|
createVisitorInsightsHandler,
|
|
1197
1202
|
daysBetween,
|
|
1198
1203
|
formatInTimeZone,
|
|
1199
1204
|
isReportName,
|
|
1200
1205
|
isValidTimeZone,
|
|
1201
1206
|
ok,
|
|
1207
|
+
parseServiceAccountKey,
|
|
1202
1208
|
partial,
|
|
1203
1209
|
previousRange,
|
|
1204
1210
|
provisionalDates,
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/types.ts","../src/core/siteConfig.ts","../src/core/cutover.ts","../src/core/ranges.ts","../src/server/auth.ts","../src/server/googleAuth.ts","../src/server/ga4.ts","../src/server/vercel.ts","../src/server/cache.ts","../src/server/orders.ts","../src/server/reports/measurementHealth.ts","../src/server/reports/acquisition.ts","../src/server/reports/journey.ts","../src/server/reports/typefaceInterest.ts","../src/server/diagnostics.ts","../src/server/createHandler.ts"],"sourcesContent":["/**\n * Server entry point — Node only.\n *\n * Nothing here may be imported from the Studio entry (`src/index.ts`). This module reaches\n * node:crypto and reads service-account credentials from the environment; keeping it behind its\n * own export subpath is what guarantees a bundler cannot pull it into a Studio browser bundle.\n */\n\nexport { createVisitorInsightsHandler, ENV_VARS, type HandlerOptions } from './server/createHandler'\nexport type { HandlerRequest, HandlerResponse, StudioUser } from './server/auth'\nexport { verifyStudioRequest, requireStudioUser, applyCors } from './server/auth'\nexport type { SanityQueryClient } from './server/orders'\nexport { clearCache } from './server/cache'\n\n// Report result shapes, so a consuming site can type a custom surface against them.\nexport type { MeasurementHealthData } from './server/reports/measurementHealth'\nexport type { AcquisitionData, SourceRow } from './server/reports/acquisition'\nexport type { JourneyData, JourneyStep, ExitPage } from './server/reports/journey'\nexport type { TypefaceInterestData, TypefaceInterestRow } from './server/reports/typefaceInterest'\nexport { JOURNEY_STEPS } from './server/reports/journey'\nexport { DESIGN_INDUSTRY_SOURCES } from './server/reports/acquisition'\nexport { runDiagnostics, type DiagnosticsInput } from './server/diagnostics'\nexport type { CheckStatus, DiagnosticCheck, DiagnosticReport } from './reportData'\n\n// Shared contract, re-exported so the server entry is self-sufficient.\nexport * from './types'\nexport * from './core/cutover'\nexport * from './core/siteConfig'\nexport * from './core/ranges'\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 * 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 * 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 * 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","/**\n * Request authentication and CORS for the report handler.\n *\n * The Studio forwards its own Sanity session token and this verifies it against Sanity. A shared\n * secret was the obvious alternative and is the wrong one: the Studio bundle is served publicly, so\n * any secret compiled into it is extractable, which moves the bar from \"know the URL\" to \"open\n * devtools\". Verifying a session token instead proves the caller is a logged-in project user, and\n * gives a real identity to log rather than an anonymous caller who read a constant.\n *\n * This mirrors the pattern already in production on Darden's order-action endpoints.\n */\n\n/** Minimal shape this module needs from a Next.js API request. */\nexport interface HandlerRequest {\n\tmethod?: string\n\turl?: string\n\theaders: Record<string, string | string[] | undefined>\n\tquery?: Record<string, string | string[] | undefined>\n}\n\n/** Minimal shape this module needs from a Next.js API response. */\nexport interface HandlerResponse {\n\tsetHeader(name: string, value: string): void\n\tstatus(code: number): HandlerResponse\n\tjson(body: unknown): void\n\tend(): void\n}\n\n/** A verified Sanity Studio user. */\nexport interface StudioUser {\n\tid: string\n\tname?: string\n\temail?: string\n\troles?: Array<{ name: string }>\n}\n\n/** Outcome of verifying a request's Studio token. */\nexport type VerifyResult =\n\t| { ok: true; user: StudioUser }\n\t| { ok: false; reason: string }\n\n/** Read a header that may arrive as a string or an array. */\nfunction header(req: HandlerRequest, name: string): string | undefined {\n\tconst raw = req.headers[name] ?? req.headers[name.toLowerCase()]\n\treturn Array.isArray(raw) ? raw[0] : raw\n}\n\n/**\n * Verify that a request carries a valid Sanity user token for this project.\n *\n * @param req - the incoming request\n * @param sanityProjectId - project the token must belong to\n * @returns ok with the resolved user, or a reason suitable for logging but never for the response\n */\nexport async function verifyStudioRequest(req: HandlerRequest, sanityProjectId: string): Promise<VerifyResult> {\n\tconst authorization = header(req, 'authorization') ?? ''\n\tconst token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : null\n\n\tif (!token) return { ok: false, reason: 'no-token' }\n\tif (!sanityProjectId) {\n\t\t// Fail closed rather than silently authorising everything if the env is misconfigured.\n\t\tconsole.error('Visitor insights: Sanity project id not configured — cannot verify Studio requests')\n\t\treturn { ok: false, reason: 'not-configured' }\n\t}\n\n\ttry {\n\t\tconst response = await fetch(`https://${sanityProjectId}.api.sanity.io/v2021-06-07/users/me`, {\n\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t})\n\t\tif (!response.ok) return { ok: false, reason: `sanity-${response.status}` }\n\n\t\tconst user = (await response.json()) as StudioUser | null\n\t\t// Sanity answers 200 with a null id for an unauthenticated request rather than a 401.\n\t\tif (!user || !user.id) return { ok: false, reason: 'no-user' }\n\n\t\treturn { ok: true, user }\n\t} catch (e) {\n\t\tconsole.error('Visitor insights: Studio token verification failed:', (e as Error).message)\n\t\treturn { ok: false, reason: 'verify-error' }\n\t}\n}\n\n/**\n * Apply CORS headers for a Studio-originated request and answer the preflight.\n *\n * Echoes one allow-listed origin rather than sending `*`. A wildcard is acceptable for public HTML\n * but not for an endpoint that takes an Authorization header and returns business data.\n *\n * @param req - the incoming request\n * @param res - the response to decorate\n * @param allowedOrigins - exact origins permitted to call cross-origin\n * @returns true when the request was a preflight and is now fully answered\n */\nexport function applyCors(req: HandlerRequest, res: HandlerResponse, allowedOrigins: readonly string[]): boolean {\n\tconst origin = header(req, 'origin')\n\n\tif (origin && allowedOrigins.includes(origin)) {\n\t\tres.setHeader('Access-Control-Allow-Origin', origin)\n\t\t// The response varies by origin, so caches must not serve one origin's response to another.\n\t\tres.setHeader('Vary', 'Origin')\n\t\tres.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')\n\t\tres.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')\n\t\tres.setHeader('Access-Control-Max-Age', '600')\n\t}\n\n\tif (req.method === 'OPTIONS') {\n\t\t// A disallowed origin gets 204 without allow headers; the browser rejects it on its own,\n\t\t// so there is no need to reveal whether the origin is known.\n\t\tres.status(204).end()\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n/**\n * Guard a report endpoint. Responds 401 and returns null when the caller cannot be verified.\n *\n * @returns the verified user, or null when the request has already been answered with a 401\n */\nexport async function requireStudioUser(\n\treq: HandlerRequest,\n\tres: HandlerResponse,\n\tsanityProjectId: string,\n): Promise<StudioUser | null> {\n\tconst result = await verifyStudioRequest(req, sanityProjectId)\n\n\tif (result.ok) return result.user\n\n\tconsole.error(`Visitor insights: rejected unauthenticated request (${result.reason}) on ${req.url ?? 'unknown'}`)\n\tres.status(401).json({ error: 'Not authorised. Sign in to the Studio and try again.' })\n\treturn null\n}\n","/**\n * Service-account access tokens for the GA4 Data API.\n *\n * Implemented directly against Google's OAuth2 token endpoint rather than through `googleapis`,\n * which is a very large dependency to pull into a package that only needs one grant type. Signing\n * a JWT with node:crypto keeps the dependency surface at zero and removes any risk of a\n * credential-bearing SDK being reachable from the Studio entry point.\n */\n\nimport { createSign } from 'node:crypto'\n\n/** Read-only scope for the GA4 Data API — the least privilege this package needs. */\nconst ANALYTICS_READONLY_SCOPE = 'https://www.googleapis.com/auth/analytics.readonly'\n\n/** Google's OAuth2 token endpoint for the JWT bearer grant. */\nconst TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'\n\n/** Seconds an assertion stays valid. Google caps this at one hour. */\nconst ASSERTION_TTL_SECONDS = 3600\n\n/** Refresh this many seconds before actual expiry, so a token never expires mid-flight. */\nconst REFRESH_MARGIN_SECONDS = 60\n\n/** The parts of a service-account JSON key this module uses. */\nexport interface ServiceAccountKey {\n\tclient_email: string\n\tprivate_key: string\n}\n\ninterface CachedToken {\n\ttoken: string\n\texpiresAt: number\n}\n\n/** Access tokens are cached per service account for their lifetime. */\nconst tokenCache = new Map<string, CachedToken>()\n\n/** Base64url without padding, as JWT requires. */\nfunction base64url(input: string | Buffer): string {\n\treturn Buffer.from(input).toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\n/**\n * Parse a service-account key from an environment variable.\n *\n * Accepts either raw JSON or base64-encoded JSON, because multi-line JSON with embedded newlines\n * is awkward to set in some dashboards and gets mangled often enough to be worth tolerating both.\n *\n * @param raw - the environment variable's value\n * @returns the parsed key, or null when unset or unparseable\n */\nexport function parseServiceAccountKey(raw: string | undefined): ServiceAccountKey | null {\n\tif (!raw) return null\n\n\tlet text = raw.trim()\n\n\t// Base64 payloads contain no braces; JSON always starts with one.\n\tif (!text.startsWith('{')) {\n\t\ttry {\n\t\t\ttext = Buffer.from(text, 'base64').toString('utf8')\n\t\t} catch {\n\t\t\treturn null\n\t\t}\n\t}\n\n\ttry {\n\t\tconst parsed = JSON.parse(text) as Partial<ServiceAccountKey>\n\t\tif (!parsed.client_email || !parsed.private_key) return null\n\n\t\treturn {\n\t\t\tclient_email: parsed.client_email,\n\t\t\t// Escaped newlines survive most env-var round trips; real newlines do not always.\n\t\t\tprivate_key: parsed.private_key.replace(/\\\\n/g, '\\n'),\n\t\t}\n\t} catch {\n\t\treturn null\n\t}\n}\n\n/**\n * Get an access token for the GA4 Data API, minting one only when the cached token is near expiry.\n *\n * @param key - the service-account key\n * @returns a bearer token\n * @throws when Google rejects the assertion\n */\nexport async function getAccessToken(key: ServiceAccountKey): Promise<string> {\n\tconst now = Math.floor(Date.now() / 1000)\n\tconst cached = tokenCache.get(key.client_email)\n\n\tif (cached && cached.expiresAt - REFRESH_MARGIN_SECONDS > now) {\n\t\treturn cached.token\n\t}\n\n\tconst header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))\n\tconst claims = base64url(\n\t\tJSON.stringify({\n\t\t\tiss: key.client_email,\n\t\t\tscope: ANALYTICS_READONLY_SCOPE,\n\t\t\taud: TOKEN_ENDPOINT,\n\t\t\texp: now + ASSERTION_TTL_SECONDS,\n\t\t\tiat: now,\n\t\t}),\n\t)\n\n\tconst signer = createSign('RSA-SHA256')\n\tsigner.update(`${header}.${claims}`)\n\tconst signature = base64url(signer.sign(key.private_key))\n\tconst assertion = `${header}.${claims}.${signature}`\n\n\tconst response = await fetch(TOKEN_ENDPOINT, {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({\n\t\t\tgrant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n\t\t\tassertion,\n\t\t}),\n\t})\n\n\tif (!response.ok) {\n\t\t// Deliberately does not include the response body, which can echo parts of the assertion.\n\t\tthrow new Error(`Google token exchange failed with ${response.status}`)\n\t}\n\n\tconst body = (await response.json()) as { access_token?: string; expires_in?: number }\n\tif (!body.access_token) throw new Error('Google token exchange returned no access token')\n\n\ttokenCache.set(key.client_email, {\n\t\ttoken: body.access_token,\n\t\texpiresAt: now + (body.expires_in ?? ASSERTION_TTL_SECONDS),\n\t})\n\n\treturn body.access_token\n}\n\n/** Clear the token cache. Test seam. */\nexport function resetTokenCache(): void {\n\ttokenCache.clear()\n}\n","/**\n * GA4 Data API client.\n *\n * Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:\n * it is an alpha surface with its own stricter quota, and it returns step-conversion marginals\n * rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was\n * never measured. The journey report approximates instead, and says so.\n */\n\nimport { getAccessToken, type ServiceAccountKey } from './googleAuth'\n\nconst DATA_API_BASE = 'https://analyticsdata.googleapis.com/v1beta'\n\n/** A GA4 report request, narrowed to the fields this package sets. */\nexport interface Ga4ReportRequest {\n\tdimensions?: Array<{ name: string }>\n\tmetrics?: Array<{ name: string }>\n\tdateRanges: Array<{ startDate: string; endDate: string }>\n\tdimensionFilter?: unknown\n\torderBys?: unknown\n\tlimit?: number\n\tkeepEmptyRows?: boolean\n}\n\n/** A parsed report row: dimension values and metric values, positionally aligned to the request. */\nexport interface Ga4Row {\n\tdimensions: string[]\n\tmetrics: number[]\n}\n\n/** A parsed GA4 report. */\nexport interface Ga4Report {\n\trows: Ga4Row[]\n\t/** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */\n\tthresholded: boolean\n\t/** True when GA4 answered from a sample rather than the full data set. */\n\tsampled: boolean\n\t/** Total row count GA4 reports, which may exceed rows returned when a limit applied. */\n\trowCount: number\n\t/**\n\t * The property's configured timezone, as GA4 reports it. Worth capturing because every range\n\t * is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.\n\t */\n\ttimeZone?: string\n}\n\n/** Raw Data API response shape, narrowed to what is read here. */\ninterface RawReport {\n\trows?: Array<{\n\t\tdimensionValues?: Array<{ value?: string }>\n\t\tmetricValues?: Array<{ value?: string }>\n\t}>\n\trowCount?: number\n\tmetadata?: { subjectToThresholding?: boolean; samplingMetadatas?: unknown[]; timeZone?: string }\n\tpropertyQuota?: unknown\n}\n\n/**\n * Coerce a GA4 metric cell to a number.\n *\n * The Data API returns every value as a string, and returns an empty string for suppressed cells.\n * `Number('')` is 0, which would turn a withheld row into a real-looking zero — so empty is\n * treated as NaN and filtered by the caller rather than silently becoming a data point.\n */\nfunction toMetricNumber(raw: string | undefined): number {\n\tif (raw === undefined || raw === '') return Number.NaN\n\tconst parsed = Number(raw)\n\treturn Number.isFinite(parsed) ? parsed : Number.NaN\n}\n\n/** Parse a raw Data API report into the shape the reports consume. */\nfunction parseReport(raw: RawReport): Ga4Report {\n\tconst rows: Ga4Row[] = (raw.rows ?? []).map((row) => ({\n\t\tdimensions: (row.dimensionValues ?? []).map((d) => d.value ?? ''),\n\t\tmetrics: (row.metricValues ?? []).map((m) => toMetricNumber(m.value)),\n\t}))\n\n\treturn {\n\t\trows,\n\t\tthresholded: raw.metadata?.subjectToThresholding === true,\n\t\tsampled: Array.isArray(raw.metadata?.samplingMetadatas) && raw.metadata.samplingMetadatas.length > 0,\n\t\trowCount: raw.rowCount ?? rows.length,\n\t\ttimeZone: raw.metadata?.timeZone,\n\t}\n}\n\n/** A GA4 client bound to one property. */\nexport interface Ga4Client {\n\trunReport(request: Ga4ReportRequest): Promise<Ga4Report>\n\tbatchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>\n}\n\n/**\n * Build a GA4 client for one property.\n *\n * @param propertyId - numeric GA4 property id\n * @param key - service-account key with Viewer on that property\n */\nexport function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client {\n\tasync function post<T>(path: string, body: unknown): Promise<T> {\n\t\tconst token = await getAccessToken(key)\n\n\t\tconst response = await fetch(`${DATA_API_BASE}/properties/${propertyId}:${path}`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${token}`,\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t},\n\t\t\tbody: JSON.stringify(body),\n\t\t})\n\n\t\tif (!response.ok) {\n\t\t\t// GA4 error bodies can echo the request; keep only the status for the caller's log.\n\t\t\tthrow new Error(`GA4 ${path} failed with ${response.status}`)\n\t\t}\n\n\t\treturn (await response.json()) as T\n\t}\n\n\treturn {\n\t\tasync runReport(request) {\n\t\t\tconst raw = await post<RawReport>('runReport', request)\n\t\t\treturn parseReport(raw)\n\t\t},\n\n\t\t/**\n\t\t * Run several reports in one call. Preferred over parallel runReport calls: it is one\n\t\t * quota-charged request and one round trip rather than N of each.\n\t\t */\n\t\tasync batchRunReports(requests) {\n\t\t\tif (requests.length === 0) return []\n\n\t\t\tconst raw = await post<{ reports?: RawReport[] }>('batchRunReports', { requests })\n\t\t\treturn (raw.reports ?? []).map(parseReport)\n\t\t},\n\t}\n}\n\n/** Build a dimension filter matching a single event name. */\nexport function eventNameFilter(eventName: string): unknown {\n\treturn {\n\t\tfilter: {\n\t\t\tfieldName: 'eventName',\n\t\t\tstringFilter: { matchType: 'EXACT', value: eventName },\n\t\t},\n\t}\n}\n\n/** Sum a report's first metric across all rows, ignoring suppressed cells. */\nexport function sumFirstMetric(report: Ga4Report): number {\n\treturn report.rows.reduce((total, row) => {\n\t\tconst value = row.metrics[0]\n\t\treturn value !== undefined && Number.isFinite(value) ? total + value : total\n\t}, 0)\n}\n","/**\n * Vercel Web Analytics client.\n *\n * Vercel's Web Analytics read API is not a stable, versioned public surface the way the GA4 Data\n * API is, and access depends on plan tier. This client is therefore written to degrade rather than\n * throw: a missing token, an unavailable plan, or a changed endpoint all surface as an unconfigured\n * or errored source in the report envelope, so the Measurement Health panel can say \"Vercel did not\n * answer\" instead of implying the site had no traffic.\n *\n * Vercel is counted here purely as a second, cookieless pageview measurement. It is never treated\n * as ground truth, and never subtracted from a GA4 session count — those are different units.\n */\n\nconst API_BASE = 'https://api.vercel.com'\n\n/** Daily pageview counts, keyed by ISO date. */\nexport interface VercelPageviews {\n\tbyDate: Record<string, number>\n\ttotal: number\n}\n\n/** A Vercel client bound to one project. */\nexport interface VercelClient {\n\tpageviews(start: string, end: string): Promise<VercelPageviews>\n}\n\n/**\n * Build a Vercel Web Analytics client.\n *\n * @param projectId - the Vercel project id\n * @param token - a Vercel API token with read access to that project\n * @param teamId - team scope, required when the project belongs to a team\n */\nexport function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient {\n\treturn {\n\t\tasync pageviews(start, end) {\n\t\t\tconst params = new URLSearchParams({\n\t\t\t\tprojectId,\n\t\t\t\tsince: `${start}T00:00:00.000Z`,\n\t\t\t\tuntil: `${end}T23:59:59.999Z`,\n\t\t\t})\n\t\t\tif (teamId) params.set('teamId', teamId)\n\n\t\t\tconst response = await fetch(`${API_BASE}/v1/web-analytics/timeseries?${params.toString()}`, {\n\t\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t\t})\n\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`Vercel Web Analytics failed with ${response.status}`)\n\t\t\t}\n\n\t\t\tconst body = (await response.json()) as { data?: Array<{ key?: string; total?: number; devices?: number }> }\n\t\t\tconst byDate: Record<string, number> = {}\n\t\t\tlet total = 0\n\n\t\t\tfor (const point of body.data ?? []) {\n\t\t\t\tif (!point.key) continue\n\t\t\t\t// `key` is a timestamp or date string depending on granularity; normalise to a date.\n\t\t\t\tconst date = point.key.slice(0, 10)\n\t\t\t\tconst count = typeof point.total === 'number' ? point.total : 0\n\t\t\t\tbyDate[date] = (byDate[date] ?? 0) + count\n\t\t\t\ttotal += count\n\t\t\t}\n\n\t\t\treturn { byDate, total }\n\t\t},\n\t}\n}\n","/**\n * A small in-memory TTL cache for report responses.\n *\n * Without this, every panel render and every range toggle is a live fan-out to GA4, Vercel and\n * Sanity. GA4's standard quota is finite per property per day and shared with anything else\n * querying it, so an editor idly switching ranges could exhaust it. Reports also change slowly —\n * GA4 does not finalise the last two days at all — so serving a few minutes stale costs nothing.\n *\n * Deliberately per-instance rather than a shared store: it is a quota guard and a latency\n * smoother, not a source of truth, and a cold serverless instance simply repopulates it.\n */\n\n/** How long a cached report stays fresh. */\nexport const DEFAULT_TTL_MS = 5 * 60 * 1000\n\ninterface Entry<T> {\n\tvalue: T\n\texpiresAt: number\n}\n\n/** Bound the cache so a long-lived instance cannot grow without limit. */\nconst MAX_ENTRIES = 200\n\nconst store = new Map<string, Entry<unknown>>()\n\n/** Build a stable cache key from the parts that determine a response. */\nexport function cacheKey(parts: Array<string | number | undefined>): string {\n\treturn parts.map((p) => String(p ?? '')).join('|')\n}\n\n/**\n * Read a fresh cached value, or undefined when absent or stale.\n * Stale entries are evicted on read so expiry does not depend on a sweep.\n */\nexport function getCached<T>(key: string): T | undefined {\n\tconst entry = store.get(key)\n\tif (!entry) return undefined\n\n\tif (entry.expiresAt <= Date.now()) {\n\t\tstore.delete(key)\n\t\treturn undefined\n\t}\n\n\treturn entry.value as T\n}\n\n/** Store a value with a TTL, evicting the oldest entry when full. */\nexport function setCached<T>(key: string, value: T, ttlMs: number = DEFAULT_TTL_MS): void {\n\tif (store.size >= MAX_ENTRIES && !store.has(key)) {\n\t\t// Map preserves insertion order, so the first key is the oldest.\n\t\tconst oldest = store.keys().next().value\n\t\tif (oldest !== undefined) store.delete(oldest)\n\t}\n\n\tstore.set(key, { value, expiresAt: Date.now() + ttlMs })\n}\n\n/**\n * Return a cached value or compute, cache and return it.\n * Concurrent callers may both compute on a cold key; that is acceptable here and avoids the\n * complexity of an in-flight promise registry for a cache this small.\n */\nexport async function withCache<T>(key: string, ttlMs: number, compute: () => Promise<T>): Promise<T> {\n\tconst hit = getCached<T>(key)\n\tif (hit !== undefined) return hit\n\n\tconst value = await compute()\n\tsetCached(key, value, ttlMs)\n\treturn value\n}\n\n/** Empty the cache. Test seam, and useful after a config change. */\nexport function clearCache(): void {\n\tstore.clear()\n}\n","/**\n * Order counts from Sanity, used only as a conversion anchor.\n *\n * Orders on these sites carry customer names, emails and postal addresses. Joining that to\n * behavioural analytics would turn aggregate statistics into personal-data processing and put the\n * GA4 property at risk under Google's terms, so no query in this file may project a PII field.\n * The projections below are allow-lists, not conveniences: every GROQ query here names the exact\n * fields it returns, and none of them is a customer identifier.\n *\n * Revenue is likewise absent. This package reports behaviour; the sales portal reports sales.\n */\n\n/** Fields safe to read from an order. Nothing here identifies a person. */\nconst SAFE_ORDER_PROJECTION = '{ _createdAt, orderStatus }'\n\n/** A daily count of orders. */\nexport interface OrderCounts {\n\tbyDate: Record<string, number>\n\ttotal: number\n}\n\n/** Orders attributable to a typeface, for the interest report. */\nexport interface TypefaceOrderCounts {\n\t/** Typeface title to order count. */\n\tbyTypeface: Record<string, number>\n\ttotal: number\n}\n\n/** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */\nexport interface SanityQueryClient {\n\tfetch<T>(query: string, params?: Record<string, unknown>): Promise<T>\n}\n\n/**\n * Build a GROQ filter for orders in a date range.\n * `excludeFilter` lets a site drop non-typeface orders, e.g. merch-only orders on Darden, which\n * would otherwise inflate a family's apparent purchase count.\n */\nfunction orderFilter(documentType: string, excludeFilter?: string): string {\n\tconst clauses = [\n\t\t`_type == $documentType`,\n\t\t`_createdAt >= $start`,\n\t\t`_createdAt <= $end`,\n\t]\n\tif (excludeFilter) clauses.push(`(${excludeFilter})`)\n\treturn clauses.join(' && ')\n}\n\n/**\n * Count orders per day across a range.\n *\n * Bounds are passed as full timestamps because `_createdAt` is a UTC datetime; comparing it to a\n * bare date would silently drop the last day's orders.\n *\n * @param client - a Sanity client\n * @param documentType - the site's order document type\n * @param start - inclusive ISO date\n * @param end - inclusive ISO date\n * @param excludeFilter - optional GROQ clause excluding non-typeface orders\n */\nexport async function countOrders(\n\tclient: SanityQueryClient,\n\tdocumentType: string,\n\tstart: string,\n\tend: string,\n\texcludeFilter?: string,\n): Promise<OrderCounts> {\n\tconst query = `*[${orderFilter(documentType, excludeFilter)}]${SAFE_ORDER_PROJECTION}`\n\n\tconst orders = await client.fetch<Array<{ _createdAt: string }>>(query, {\n\t\tdocumentType,\n\t\tstart: `${start}T00:00:00Z`,\n\t\tend: `${end}T23:59:59Z`,\n\t})\n\n\tconst byDate: Record<string, number> = {}\n\tfor (const order of orders) {\n\t\tconst date = order._createdAt.slice(0, 10)\n\t\tbyDate[date] = (byDate[date] ?? 0) + 1\n\t}\n\n\treturn { byDate, total: orders.length }\n}\n\n/**\n * Count orders per typeface across a range.\n *\n * Returns an empty result when the site has no usable typeface field on orders, rather than\n * guessing — an absent join is reported as unavailable upstream, not as zero purchases.\n *\n * @param typefacesField - the order field holding typeface references, or null when absent\n */\nexport async function countOrdersByTypeface(\n\tclient: SanityQueryClient,\n\tdocumentType: string,\n\ttypefacesField: string | null,\n\tstart: string,\n\tend: string,\n\texcludeFilter?: string,\n): Promise<TypefaceOrderCounts | null> {\n\tif (!typefacesField) return null\n\n\t// Dereferences only the typeface title — never the order's customer fields.\n\tconst query = `*[${orderFilter(documentType, excludeFilter)}]{\n\t\t\"typefaces\": ${typefacesField}[]->{ title }\n\t}`\n\n\tconst orders = await client.fetch<Array<{ typefaces?: Array<{ title?: string } | null> | null }>>(query, {\n\t\tdocumentType,\n\t\tstart: `${start}T00:00:00Z`,\n\t\tend: `${end}T23:59:59Z`,\n\t})\n\n\tconst byTypeface: Record<string, number> = {}\n\tlet total = 0\n\n\tfor (const order of orders) {\n\t\tfor (const typeface of order.typefaces ?? []) {\n\t\t\tconst title = typeface?.title\n\t\t\tif (!title) continue\n\t\t\tbyTypeface[title] = (byTypeface[title] ?? 0) + 1\n\t\t\ttotal += 1\n\t\t}\n\t}\n\n\treturn { byTypeface, total }\n}\n","/**\n * Measurement Health — \"what are we missing?\", answered honestly.\n *\n * This panel began life as a \"coverage gap\" that subtracted Vercel pageviews from GA4 sessions and\n * attributed the difference to consent, ad-blockers and bots. That is not a valid subtraction:\n * sessions and pageviews are different units, so the difference is dominated by the unit mismatch\n * rather than by anything missing. Worse, GA4 exposes no signal that separates consent-denied from\n * blocked from bot traffic, so the attribution would have been a guess rendered with the authority\n * of a measurement.\n *\n * What this reports instead:\n * - GA4 pageviews against Vercel pageviews, the same unit on both sides\n * - GA4 sessions and orders alongside as context, never subtracted\n * - one residual, labelled unexplained, with the causes we can actually measure broken out\n *\n * The only cause we can measure is consent: a `consent_granted` event makes the acceptance rate a\n * real number rather than an inference. Until that event is instrumented, the residual stays whole\n * and the panel says why.\n */\n\nimport type { MeasurementHealthData } from '../../reportData'\nimport type { DateRange, MetricValue } from '../../types'\nimport { ok, unavailable } from '../../types'\nimport type { SiteAnalyticsConfig } from '../../core/siteConfig'\nimport { coverageForRange } from '../../core/cutover'\nimport { sumFirstMetric, type Ga4Client } from '../ga4'\nimport type { VercelClient } from '../vercel'\nimport { countOrders, type SanityQueryClient } from '../orders'\n\n/** The event whose presence turns the consent share from a guess into a measurement. */\nconst CONSENT_EVENT = 'consent_granted'\n\n\n/** Inputs for the measurement-health report. */\nexport interface MeasurementHealthInput {\n\tconfig: SiteAnalyticsConfig\n\trange: DateRange\n\tga4: Ga4Client | null\n\tvercel: VercelClient | null\n\tsanity: SanityQueryClient | null\n}\n\n/** Build the plain-language reading shown beneath the figures. */\nfunction interpret(ga4Views: MetricValue, vercelViews: MetricValue, shortfall: number | null, consent: MetricValue): string {\n\tif (ga4Views.status === 'unavailable' && vercelViews.status === 'unavailable') {\n\t\treturn 'Neither source answered, so nothing can be said about coverage for this range.'\n\t}\n\tif (shortfall === null) {\n\t\treturn 'Only one pageview source answered, so the two cannot be compared for this range.'\n\t}\n\n\tconst percent = Math.round(Math.abs(shortfall) * 100)\n\n\tif (Math.abs(shortfall) < 0.05) {\n\t\treturn `GA4 and Vercel agree to within ${percent}% on pageviews. Nothing here suggests a measurement problem.`\n\t}\n\n\tconst direction = shortfall > 0 ? 'fewer' : 'more'\n\tconst base = `GA4 recorded ${percent}% ${direction} pageviews than Vercel.`\n\n\tif (consent.status === 'unavailable') {\n\t\treturn `${base} How much of that is consent refusal cannot be measured until the consent_granted event is instrumented, so the difference is currently unexplained rather than attributed.`\n\t}\n\n\tconst consentPercent = Math.round((1 - consent.value / 100) * 100)\n\treturn `${base} Around ${consentPercent}% of sessions did not grant analytics consent, which accounts for part of it. The remainder is unexplained — ad-blocking and bot filtering are plausible but are not separately measurable.`\n}\n\n/**\n * Run the measurement-health report.\n *\n * Each source is queried independently and a failure in one degrades only its own figures, so the\n * panel can always say which source did not answer rather than showing an unexplained blank.\n */\nexport async function measurementHealth(input: MeasurementHealthInput): Promise<MeasurementHealthData> {\n\tconst { config, range, ga4, vercel, sanity } = input\n\n\tlet ga4Pageviews: MetricValue = unavailable('source_error', 'GA4 not configured')\n\tlet ga4Sessions: MetricValue = unavailable('source_error', 'GA4 not configured')\n\tlet consentRate: MetricValue = unavailable('not_instrumented')\n\n\tif (ga4) {\n\t\ttry {\n\t\t\t// One batched call rather than three separate quota-charged requests.\n\t\t\tconst [views, sessions, consent] = await ga4.batchRunReports([\n\t\t\t\t{\n\t\t\t\t\tmetrics: [{ name: 'screenPageViews' }],\n\t\t\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tmetrics: [{ name: 'sessions' }],\n\t\t\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\t\tdimensions: [{ name: 'eventName' }],\n\t\t\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\t\t\tdimensionFilter: {\n\t\t\t\t\t\tfilter: {\n\t\t\t\t\t\t\tfieldName: 'eventName',\n\t\t\t\t\t\t\tstringFilter: { matchType: 'EXACT', value: CONSENT_EVENT },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t])\n\n\t\t\tif (views) ga4Pageviews = ok(sumFirstMetric(views))\n\t\t\tif (sessions) ga4Sessions = ok(sumFirstMetric(sessions))\n\n\t\t\tconst consentCoverage = coverageForRange(config.eventCutovers, CONSENT_EVENT, range)\n\t\t\tif (consentCoverage.status === 'full' && consent && ga4Sessions.status === 'ok' && ga4Sessions.value > 0) {\n\t\t\t\t// Expressed as a percentage of sessions, capped since one session can fire it twice.\n\t\t\t\tconst rate = Math.min(100, (sumFirstMetric(consent) / ga4Sessions.value) * 100)\n\t\t\t\tconsentRate = ok(Math.round(rate * 10) / 10)\n\t\t\t} else if (consentCoverage.status === 'partial') {\n\t\t\t\tconsentRate = unavailable('before_cutover', `Instrumented from ${consentCoverage.cutover}`)\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: GA4 measurement-health query failed:', (e as Error).message)\n\t\t\tga4Pageviews = unavailable('source_error')\n\t\t\tga4Sessions = unavailable('source_error')\n\t\t}\n\t}\n\n\tlet vercelPageviews: MetricValue = unavailable('source_error', 'Vercel not configured')\n\tif (vercel) {\n\t\ttry {\n\t\t\tconst result = await vercel.pageviews(range.start, range.end)\n\t\t\tvercelPageviews = ok(result.total)\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: Vercel query failed:', (e as Error).message)\n\t\t\tvercelPageviews = unavailable('source_error')\n\t\t}\n\t}\n\n\tlet orders: MetricValue = unavailable('source_error', 'Sanity not configured')\n\tif (sanity) {\n\t\ttry {\n\t\t\tconst counts = await countOrders(\n\t\t\t\tsanity,\n\t\t\t\tconfig.orders.documentType,\n\t\t\t\trange.start,\n\t\t\t\trange.end,\n\t\t\t\tconfig.orders.excludeFilter,\n\t\t\t)\n\t\t\torders = ok(counts.total)\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: order count failed:', (e as Error).message)\n\t\t\torders = unavailable('source_error')\n\t\t}\n\t}\n\n\t// Computed only when both operands are real numbers, and only between matching units.\n\tconst shortfallRatio =\n\t\tga4Pageviews.status !== 'unavailable' && vercelPageviews.status !== 'unavailable' && vercelPageviews.value > 0\n\t\t\t? (vercelPageviews.value - ga4Pageviews.value) / vercelPageviews.value\n\t\t\t: null\n\n\treturn {\n\t\tga4Pageviews,\n\t\tvercelPageviews,\n\t\tshortfallRatio,\n\t\tga4Sessions,\n\t\torders,\n\t\tconsentRate,\n\t\tinterpretation: interpret(ga4Pageviews, vercelPageviews, shortfallRatio, consentRate),\n\t}\n}\n\nexport type { MeasurementHealthData } from '../../reportData'\n","/**\n * Acquisition — where visitors come from.\n *\n * Design-industry referrers are pulled out as a named segment rather than left in a generic\n * referrer table. A visit from Fonts In Use or Typewolf is a pre-qualified, industry-literate\n * visitor and behaves nothing like average organic traffic; burying those rows among search and\n * social discards the distinction a foundry actually acts on.\n *\n * `(not set)` rows are surfaced rather than swept into \"other\". On a type-foundry site they can be\n * a large share — bookmarked visits, stripped referrers, AI crawlers — and hiding them makes the\n * table look more complete than it is.\n */\n\nimport type { AcquisitionData, SourceRow } from '../../reportData'\nimport type { DateRange } from '../../types'\nimport { sumFirstMetric, type Ga4Client } from '../ga4'\n\n/** Referrer hosts that identify a design-industry source worth tracking separately. */\nexport const DESIGN_INDUSTRY_SOURCES = [\n\t'fontsinuse.com',\n\t'typewolf.com',\n\t'typographica.org',\n\t'fonts.google.com',\n\t'behance.net',\n\t'dribbble.com',\n\t'itsnicethat.com',\n] as const\n\n\n\n/** Whether a GA4 source value is one of the unattributed buckets rather than a real referrer. */\nfunction isUnattributed(source: string): boolean {\n\tconst normalised = source.toLowerCase()\n\treturn normalised === '(not set)' || normalised === '(direct)' || normalised === '(none)' || normalised === ''\n}\n\n/** Whether a GA4 source value matches a design-industry referrer. */\nfunction isDesignIndustry(source: string): boolean {\n\tconst normalised = source.toLowerCase()\n\treturn DESIGN_INDUSTRY_SOURCES.some((known) => normalised === known || normalised.endsWith(`.${known}`))\n}\n\n/**\n * Run the acquisition report.\n *\n * @param ga4 - client for the site's property\n * @param range - the requested range\n * @param limit - maximum source rows to return\n */\nexport async function acquisition(ga4: Ga4Client, range: DateRange, limit = 25): Promise<AcquisitionData> {\n\tconst report = await ga4.runReport({\n\t\tdimensions: [{ name: 'sessionSource' }, { name: 'sessionDefaultChannelGroup' }],\n\t\tmetrics: [{ name: 'sessions' }],\n\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\torderBys: [{ metric: { metricName: 'sessions' }, desc: true }],\n\t\tlimit,\n\t})\n\n\tconst rows: SourceRow[] = report.rows.map((row) => {\n\t\tconst source = row.dimensions[0] ?? '(not set)'\n\t\tconst sessions = row.metrics[0]\n\n\t\treturn {\n\t\t\tsource,\n\t\t\tchannel: row.dimensions[1] ?? 'Unassigned',\n\t\t\tsessions: Number.isFinite(sessions) ? (sessions as number) : 0,\n\t\t\tdesignIndustry: isDesignIndustry(source),\n\t\t\tunattributed: isUnattributed(source),\n\t\t}\n\t})\n\n\tconst totalSessions = sumFirstMetric(report)\n\tconst sumWhere = (predicate: (row: SourceRow) => boolean) =>\n\t\trows.filter(predicate).reduce((total, row) => total + row.sessions, 0)\n\n\treturn {\n\t\trows,\n\t\ttotalSessions,\n\t\tdesignIndustryShare: totalSessions > 0 ? sumWhere((r) => r.designIndustry) / totalSessions : null,\n\t\tunattributedShare: totalSessions > 0 ? sumWhere((r) => r.unattributed) / totalSessions : null,\n\t\trowsWithheld: report.thresholded,\n\t}\n}\n\nexport type { AcquisitionData, SourceRow } from '../../reportData'\n","/**\n * Journey — how far visitors get, and where they stop.\n *\n * This is an ordered step funnel, not a path graph, and that is a deliberate limit rather than a\n * simplification. GA4's Data API has no path-exploration endpoint; Path Exploration is a UI-only\n * feature. What is available is per-event totals and `runFunnelReport`, an alpha surface that\n * returns step-conversion marginals — not observed sequences. Rendering ribbons from marginals\n * would assert that a given visitor went A to B to C when no such co-occurrence was ever measured.\n *\n * So each step is reported as its own honest total, adjacent drop-off is derived between steps, and\n * the response is explicitly flagged as an approximation for the UI to display.\n *\n * A step whose event is not instrumented on this site reports as unavailable, never as zero. On a\n * site missing `begin_checkout`, the cart-to-checkout drop-off is not \"100% drop-off\" — it is\n * unmeasured, and the two must not look alike.\n */\n\nimport type { JourneyData, JourneyStep, ExitPage } from '../../reportData'\nimport type { DateRange, MetricValue } from '../../types'\nimport type { SiteAnalyticsConfig } from '../../core/siteConfig'\nimport { applyCoverage, coverageForRange } from '../../core/cutover'\nimport { eventNameFilter, sumFirstMetric, type Ga4Client } from '../ga4'\n\n/** The funnel, in order. Each entry names the GA4 event that evidences the step. */\nexport const JOURNEY_STEPS = [\n\t{ key: 'landed', label: 'Landed', event: 'page_view' },\n\t{ key: 'viewed_typeface', label: 'Viewed a typeface', event: 'view_item' },\n\t{ key: 'tested', label: 'Used the type tester', event: 'tester_engaged' },\n\t{ key: 'added_to_cart', label: 'Added to cart', event: 'add_to_cart' },\n\t{ key: 'began_checkout', label: 'Began checkout', event: 'begin_checkout' },\n\t{ key: 'purchased', label: 'Purchased', event: 'purchase' },\n] as const\n\n\n\n\nconst APPROXIMATION_NOTE =\n\t'These are independent per-step totals, not tracked journeys. GA4 cannot report the actual path a ' +\n\t'visitor took, so a visitor counted at one step is not necessarily the same visitor counted at the next.'\n\n/**\n * Run the journey report.\n *\n * All step queries go in one batched call rather than one request per step, which would otherwise\n * make this the most quota-expensive panel in the tool.\n */\nexport async function journey(config: SiteAnalyticsConfig, ga4: Ga4Client, range: DateRange): Promise<JourneyData> {\n\t// Only query steps whose event could return data; skip the rest to save quota.\n\tconst coverages = JOURNEY_STEPS.map((step) => ({\n\t\tstep,\n\t\tcoverage: coverageForRange(config.eventCutovers, step.event, range),\n\t}))\n\n\tconst queryable = coverages.filter((entry) => entry.coverage.status !== 'none')\n\n\tconst reports = await ga4.batchRunReports(\n\t\tqueryable.map((entry) => ({\n\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\tdimensionFilter: eventNameFilter(entry.step.event),\n\t\t})),\n\t)\n\n\tconst countsByEvent = new Map<string, number>()\n\tqueryable.forEach((entry, index) => {\n\t\tconst report = reports[index]\n\t\tif (report) countsByEvent.set(entry.step.event, sumFirstMetric(report))\n\t})\n\n\tconst steps: JourneyStep[] = []\n\tlet previousMeasurable: number | null = null\n\n\tfor (const { step, coverage } of coverages) {\n\t\tconst raw = countsByEvent.has(step.event) ? (countsByEvent.get(step.event) as number) : null\n\t\tconst count = applyCoverage(raw, coverage)\n\n\t\tconst current = count.status === 'unavailable' ? null : count.value\n\t\tconst conversionFromPrevious =\n\t\t\tpreviousMeasurable !== null && current !== null && previousMeasurable > 0\n\t\t\t\t? current / previousMeasurable\n\t\t\t\t: null\n\n\t\tsteps.push({ key: step.key, label: step.label, event: step.event, count, conversionFromPrevious })\n\n\t\t// Only advance the baseline on a measurable step, so an unavailable rung does not\n\t\t// silently make the next step's conversion look like a collapse.\n\t\tif (current !== null) previousMeasurable = current\n\t}\n\n\tlet topExitPages: ExitPage[] = []\n\ttry {\n\t\tconst exits = await ga4.runReport({\n\t\t\tdimensions: [{ name: 'pagePath' }],\n\t\t\tmetrics: [{ name: 'exits' }],\n\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\torderBys: [{ metric: { metricName: 'exits' }, desc: true }],\n\t\t\tlimit: 10,\n\t\t})\n\n\t\ttopExitPages = exits.rows.map((row) => ({\n\t\t\tpath: row.dimensions[0] ?? '(unknown)',\n\t\t\texits: Number.isFinite(row.metrics[0]) ? (row.metrics[0] as number) : 0,\n\t\t}))\n\t} catch (e) {\n\t\t// Exit pages are supplementary; losing them should not fail the funnel.\n\t\tconsole.error('Visitor insights: exit-page query failed:', (e as Error).message)\n\t}\n\n\treturn { steps, topExitPages, approximate: true, approximationNote: APPROXIMATION_NOTE }\n}\n\nexport type { JourneyData, JourneyStep, ExitPage } from '../../reportData'\n","/**\n * Typeface interest — viewed, tested, bought, per family.\n *\n * This is the one panel GA4's own UI cannot produce, because it joins GA4 engagement to Sanity\n * order documents. It is also the panel most easily misread, so two things are stated in the\n * response rather than left to the reader:\n *\n * 1. It measures aggregate interest per family, not one person's journey. In foundry sales the\n * designer who tests a typeface is frequently not the person who later pays for it, and the\n * gap between the two can be months. A low tested-to-bought ratio is therefore not a broken\n * funnel, and the UI must not frame it as one.\n * 2. \"Tested\" counts only genuine engagement — a `tester_engaged` event fired when the visitor\n * changed something. Firing on tester open would count every default-state page load as a\n * test and make the ratio meaningless.\n */\n\nimport type { TypefaceInterestData, TypefaceInterestRow } from '../../reportData'\nimport type { DateRange, MetricValue } from '../../types'\nimport { ok, unavailable } from '../../types'\nimport type { SiteAnalyticsConfig } from '../../core/siteConfig'\nimport { coverageForRange } from '../../core/cutover'\nimport { eventNameFilter, type Ga4Client } from '../ga4'\nimport { countOrdersByTypeface, type SanityQueryClient } from '../orders'\n\n/** GA4 event evidencing a typeface page view. */\nconst VIEW_EVENT = 'view_item'\n\n/** GA4 event evidencing real type-tester engagement. */\nconst TEST_EVENT = 'tester_engaged'\n\n\n\nconst INTERPRETATION_NOTE =\n\t'Aggregate interest per family, not individual journeys. The person who tests a typeface is often ' +\n\t'not the person who buys it, and may buy months later — so a low tested-to-bought ratio is not ' +\n\t'necessarily a conversion problem.'\n\n/**\n * Read per-typeface event counts keyed by the GA4 `itemName` dimension.\n * Returns null when the event is not usable for this range, so the caller can distinguish\n * \"no data\" from \"no interest\".\n */\nasync function countsByItem(\n\tconfig: SiteAnalyticsConfig,\n\tga4: Ga4Client,\n\trange: DateRange,\n\teventName: string,\n): Promise<Map<string, number> | null> {\n\tif (coverageForRange(config.eventCutovers, eventName, range).status === 'none') return null\n\n\tconst report = await ga4.runReport({\n\t\tdimensions: [{ name: 'itemName' }],\n\t\tmetrics: [{ name: 'eventCount' }],\n\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\tdimensionFilter: eventNameFilter(eventName),\n\t\torderBys: [{ metric: { metricName: 'eventCount' }, desc: true }],\n\t\tlimit: 100,\n\t})\n\n\tconst counts = new Map<string, number>()\n\tfor (const row of report.rows) {\n\t\tconst name = row.dimensions[0]\n\t\tconst value = row.metrics[0]\n\t\tif (name && Number.isFinite(value)) counts.set(name, value as number)\n\t}\n\n\treturn counts\n}\n\n/** Inputs for the typeface-interest report. */\nexport interface TypefaceInterestInput {\n\tconfig: SiteAnalyticsConfig\n\trange: DateRange\n\tga4: Ga4Client\n\tsanity: SanityQueryClient | null\n}\n\n/** Run the typeface-interest report. */\nexport async function typefaceInterest(input: TypefaceInterestInput): Promise<TypefaceInterestData> {\n\tconst { config, range, ga4, sanity } = input\n\n\tconst [viewed, tested] = await Promise.all([\n\t\tcountsByItem(config, ga4, range, VIEW_EVENT),\n\t\tcountsByItem(config, ga4, range, TEST_EVENT),\n\t])\n\n\tlet bought: Map<string, number> | null = null\n\tif (sanity) {\n\t\ttry {\n\t\t\tconst counts = await countOrdersByTypeface(\n\t\t\t\tsanity,\n\t\t\t\tconfig.orders.documentType,\n\t\t\t\tconfig.orders.typefacesField,\n\t\t\t\trange.start,\n\t\t\t\trange.end,\n\t\t\t\tconfig.orders.excludeFilter,\n\t\t\t)\n\t\t\tif (counts) bought = new Map(Object.entries(counts.byTypeface))\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: per-typeface order count failed:', (e as Error).message)\n\t\t}\n\t}\n\n\t// Union of every family seen by any source, so a family that sold but was never viewed\n\t// (or vice versa) still appears rather than being silently dropped.\n\tconst families = new Set<string>([\n\t\t...(viewed?.keys() ?? []),\n\t\t...(tested?.keys() ?? []),\n\t\t...(bought?.keys() ?? []),\n\t])\n\n\tconst metric = (counts: Map<string, number> | null, family: string, missingReason: 'not_instrumented' | 'not_applicable'): MetricValue =>\n\t\tcounts === null ? unavailable(missingReason) : ok(counts.get(family) ?? 0)\n\n\tconst rows: TypefaceInterestRow[] = [...families].map((family) => {\n\t\tconst viewedMetric = metric(viewed, family, 'not_instrumented')\n\t\tconst testedMetric = metric(tested, family, 'not_instrumented')\n\t\tconst boughtMetric = bought === null ? unavailable('not_applicable', 'Orders do not resolve to typefaces on this site') : ok(bought.get(family) ?? 0)\n\n\t\tconst testRate =\n\t\t\tviewedMetric.status !== 'unavailable' &&\n\t\t\ttestedMetric.status !== 'unavailable' &&\n\t\t\tviewedMetric.value > 0\n\t\t\t\t? testedMetric.value / viewedMetric.value\n\t\t\t\t: null\n\n\t\treturn { typeface: family, viewed: viewedMetric, tested: testedMetric, bought: boughtMetric, testRate }\n\t})\n\n\t// Most-viewed first, with unavailable views sorting last rather than as zero.\n\trows.sort((a, b) => {\n\t\tconst av = a.viewed.status === 'unavailable' ? -1 : a.viewed.value\n\t\tconst bv = b.viewed.status === 'unavailable' ? -1 : b.viewed.value\n\t\treturn bv - av\n\t})\n\n\treturn { rows, interpretationNote: INTERPRETATION_NOTE, rowsWithheld: false }\n}\n\nexport type { TypefaceInterestData, TypefaceInterestRow } from '../../reportData'\n","/**\n * Preflight diagnostics — run these the moment real credentials exist.\n *\n * Every panel in this package is only as trustworthy as the property behind it, and the ways a GA4\n * property can quietly produce wrong numbers are not visible from the numbers themselves: retention\n * silently truncating a year range, a timezone that disagrees with the site config, an event the\n * config claims is live that has never actually fired, a `purchase` with no `transaction_id` to\n * join against.\n *\n * Each check is written to distinguish \"measured and fine\" from \"measured and broken\" from \"could\n * not measure\" — the same discipline the reports themselves use. Nothing here writes anything, so\n * it is safe to run against production at any time.\n */\n\nimport type { CheckStatus, DiagnosticCheck, DiagnosticReport } from '../reportData'\nimport type { SiteAnalyticsConfig } from '../core/siteConfig'\nimport { PREEXISTING } from '../core/cutover'\nimport { shiftDays, formatInTimeZone } from '../core/ranges'\nimport { eventNameFilter, sumFirstMetric, type Ga4Client } from './ga4'\nimport type { VercelClient } from './vercel'\nimport { countOrders, type SanityQueryClient } from './orders'\n\n/** Inputs for a diagnostic run. */\nexport interface DiagnosticsInput {\n\tconfig: SiteAnalyticsConfig\n\tga4: Ga4Client | null\n\tvercel: VercelClient | null\n\tsanity: SanityQueryClient | null\n\t/** Injectable clock, so the checks are deterministic under test. */\n\tnow?: Date\n}\n\n/** Severity ordering, worst last. */\nconst SEVERITY: CheckStatus[] = ['pass', 'skipped', 'warn', 'fail']\n\n/** Reduce a set of checks to the worst status present. */\nfunction worst(checks: DiagnosticCheck[]): CheckStatus {\n\treturn checks.reduce<CheckStatus>((acc, check) => (SEVERITY.indexOf(check.status) > SEVERITY.indexOf(acc) ? check.status : acc), 'pass')\n}\n\n/**\n * Run every preflight check.\n *\n * Each check is independent and its failure is caught, so one broken upstream cannot hide the\n * results of the others — the point of this report is to see everything wrong at once.\n */\nexport async function runDiagnostics(input: DiagnosticsInput): Promise<DiagnosticReport> {\n\tconst { config, ga4, vercel, sanity } = input\n\tconst now = input.now ?? new Date()\n\tconst checks: DiagnosticCheck[] = []\n\n\tconst timezone = config.ga4?.timezone ?? 'UTC'\n\tconst today = formatInTimeZone(now, timezone)\n\n\t// --- GA4 reachability -----------------------------------------------------\n\tlet ga4Reachable = false\n\tlet reportedTimeZone: string | undefined\n\n\tif (!ga4) {\n\t\tchecks.push({\n\t\t\tid: 'ga4-auth',\n\t\t\tlabel: 'GA4 reachable',\n\t\t\tstatus: 'skipped',\n\t\t\tdetail: 'GA4 is not configured for this site.',\n\t\t})\n\t} else {\n\t\ttry {\n\t\t\tconst probe = await ga4.runReport({\n\t\t\t\tmetrics: [{ name: 'sessions' }],\n\t\t\t\tdateRanges: [{ startDate: shiftDays(today, -7), endDate: today }],\n\t\t\t})\n\t\t\tga4Reachable = true\n\t\t\treportedTimeZone = probe.timeZone\n\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-auth',\n\t\t\t\tlabel: 'GA4 reachable',\n\t\t\t\tstatus: 'pass',\n\t\t\t\tdetail: `Property ${config.ga4?.propertyId} answered. ${sumFirstMetric(probe)} sessions in the last 7 days.`,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-auth',\n\t\t\t\tlabel: 'GA4 reachable',\n\t\t\t\tstatus: 'fail',\n\t\t\t\tdetail: (e as Error).message,\n\t\t\t\tremedy:\n\t\t\t\t\t'Check the service account is valid and has Viewer on this property, and that the property id is the numeric one from GA4 Admin rather than the G- measurement id.',\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- Timezone agreement ---------------------------------------------------\n\tif (!ga4Reachable) {\n\t\tchecks.push({ id: 'ga4-timezone', label: 'Timezone matches config', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else if (!reportedTimeZone) {\n\t\tchecks.push({\n\t\t\tid: 'ga4-timezone',\n\t\t\tlabel: 'Timezone matches config',\n\t\t\tstatus: 'warn',\n\t\t\tdetail: 'GA4 did not report a timezone, so the configured value could not be confirmed.',\n\t\t})\n\t} else if (reportedTimeZone !== timezone) {\n\t\tchecks.push({\n\t\t\tid: 'ga4-timezone',\n\t\t\tlabel: 'Timezone matches config',\n\t\t\tstatus: 'fail',\n\t\t\tdetail: `Config says ${timezone}; the property is set to ${reportedTimeZone}.`,\n\t\t\tremedy:\n\t\t\t\t'Set ga4.timezone to the property timezone. A mismatch shifts day and week boundaries, so GA4, Vercel and Sanity stop agreeing about which period an event belongs to.',\n\t\t})\n\t} else {\n\t\tchecks.push({\n\t\t\tid: 'ga4-timezone',\n\t\t\tlabel: 'Timezone matches config',\n\t\t\tstatus: 'pass',\n\t\t\tdetail: `Both are ${timezone}.`,\n\t\t})\n\t}\n\n\t// --- Retention window -----------------------------------------------------\n\t// Measured rather than read: the Admin setting is not exposed to the Data API, but its effect\n\t// is — probe a window well beyond the 2-month default and see whether anything comes back.\n\tif (!ga4Reachable || !ga4) {\n\t\tchecks.push({ id: 'ga4-retention', label: 'Retention covers a year', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else {\n\t\ttry {\n\t\t\tconst oldStart = shiftDays(today, -365)\n\t\t\tconst oldEnd = shiftDays(today, -120)\n\t\t\tconst old = await ga4.runReport({\n\t\t\t\tmetrics: [{ name: 'sessions' }],\n\t\t\t\tdateRanges: [{ startDate: oldStart, endDate: oldEnd }],\n\t\t\t})\n\n\t\t\tconst oldSessions = sumFirstMetric(old)\n\n\t\t\tif (oldSessions > 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-retention',\n\t\t\t\t\tlabel: 'Retention covers a year',\n\t\t\t\t\tstatus: 'pass',\n\t\t\t\t\tdetail: `${oldSessions} sessions found between ${oldStart} and ${oldEnd}, so the year range has data.`,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-retention',\n\t\t\t\t\tlabel: 'Retention covers a year',\n\t\t\t\t\tstatus: 'warn',\n\t\t\t\t\tdetail: `No sessions between ${oldStart} and ${oldEnd}. Either the site had no traffic then, or event-data retention is set shorter than that window.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\t'Check GA4 Admin, Data Settings, Data Retention and set it to 14 months. It does not backfill, so the sooner it is changed the sooner the year range becomes usable.',\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-retention',\n\t\t\t\tlabel: 'Retention covers a year',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not probe the retention window: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- Configured events actually fire --------------------------------------\n\tif (!ga4Reachable || !ga4) {\n\t\tchecks.push({ id: 'ga4-events', label: 'Configured events fire', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else {\n\t\ttry {\n\t\t\tconst seen = await ga4.runReport({\n\t\t\t\tdimensions: [{ name: 'eventName' }],\n\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\tdateRanges: [{ startDate: shiftDays(today, -30), endDate: today }],\n\t\t\t\tlimit: 200,\n\t\t\t})\n\n\t\t\tconst firing = new Set(seen.rows.map((row) => row.dimensions[0]).filter(Boolean) as string[])\n\n\t\t\t// Events the config claims are long-established but which GA4 has never seen.\n\t\t\tconst claimedButAbsent = Object.entries(config.eventCutovers)\n\t\t\t\t.filter(([name, cutover]) => cutover === PREEXISTING && !firing.has(name))\n\t\t\t\t.map(([name]) => name)\n\n\t\t\t// Events GA4 is recording that the config does not know about, so they are unreportable.\n\t\t\tconst firingButUnconfigured = [...firing].filter((name) => !(name in config.eventCutovers))\n\n\t\t\tif (claimedButAbsent.length > 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-events',\n\t\t\t\t\tlabel: 'Configured events fire',\n\t\t\t\t\tstatus: 'fail',\n\t\t\t\t\tdetail: `Config marks these as already live, but GA4 saw none in the last 30 days: ${claimedButAbsent.join(', ')}.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\t'Either the event is not actually firing, or the cutover map is wrong. Panels depending on it will show figures that look real but are not.',\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-events',\n\t\t\t\t\tlabel: 'Configured events fire',\n\t\t\t\t\tstatus: 'pass',\n\t\t\t\t\tdetail: `All events marked live are firing. ${firing.size} distinct events seen in the last 30 days.`,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (firingButUnconfigured.length > 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-unconfigured-events',\n\t\t\t\t\tlabel: 'No unreportable events',\n\t\t\t\t\tstatus: 'warn',\n\t\t\t\t\tdetail: `GA4 is recording events absent from the cutover map, so they cannot be reported: ${firingButUnconfigured.slice(0, 10).join(', ')}${firingButUnconfigured.length > 10 ? '…' : ''}.`,\n\t\t\t\t\tremedy: 'Add them to eventCutovers if they are worth reporting.',\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-events',\n\t\t\t\tlabel: 'Configured events fire',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not list events: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- purchase carries a join key ------------------------------------------\n\tif (!ga4Reachable || !ga4) {\n\t\tchecks.push({ id: 'ga4-transaction-id', label: 'Purchases carry transaction_id', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else {\n\t\ttry {\n\t\t\tconst purchases = await ga4.runReport({\n\t\t\t\tdimensions: [{ name: 'transactionId' }],\n\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\tdateRanges: [{ startDate: shiftDays(today, -90), endDate: today }],\n\t\t\t\tdimensionFilter: eventNameFilter('purchase'),\n\t\t\t\tlimit: 50,\n\t\t\t})\n\n\t\t\tconst ids = purchases.rows.map((row) => row.dimensions[0] ?? '')\n\t\t\tconst usable = ids.filter((id) => id && id !== '(not set)')\n\n\t\t\tif (ids.length === 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\t\tstatus: 'skipped',\n\t\t\t\t\tdetail: 'No purchase events in the last 90 days to inspect.',\n\t\t\t\t})\n\t\t\t} else if (usable.length === 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\t\tstatus: 'fail',\n\t\t\t\t\tdetail: `${ids.length} purchase rows, none with a usable transaction_id.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\t'Send transaction_id (the Sanity orderNumber) on every purchase event. Without it a GA4 purchase cannot be reconciled against an order, so the conversion anchor cannot be verified.',\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\t\tstatus: usable.length === ids.length ? 'pass' : 'warn',\n\t\t\t\t\tdetail: `${usable.length} of ${ids.length} purchase rows carry a transaction_id.`,\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not inspect purchases: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- GA4 purchases against Sanity orders ----------------------------------\n\tif (!ga4Reachable || !ga4 || !sanity) {\n\t\tchecks.push({\n\t\t\tid: 'purchase-order-agreement',\n\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\tstatus: 'skipped',\n\t\t\tdetail: 'Needs both GA4 and Sanity.',\n\t\t})\n\t} else {\n\t\ttry {\n\t\t\tconst start = shiftDays(today, -30)\n\t\t\tconst [ga4Purchases, orders] = await Promise.all([\n\t\t\t\tga4.runReport({\n\t\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\t\tdateRanges: [{ startDate: start, endDate: today }],\n\t\t\t\t\tdimensionFilter: eventNameFilter('purchase'),\n\t\t\t\t}),\n\t\t\t\tcountOrders(sanity, config.orders.documentType, start, today, config.orders.excludeFilter),\n\t\t\t])\n\n\t\t\tconst tracked = sumFirstMetric(ga4Purchases)\n\t\t\tconst actual = orders.total\n\n\t\t\tif (actual === 0 && tracked === 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'purchase-order-agreement',\n\t\t\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\t\t\tstatus: 'skipped',\n\t\t\t\t\tdetail: 'No orders and no purchase events in the last 30 days.',\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tconst divergence = actual > 0 ? Math.abs(tracked - actual) / actual : 1\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'purchase-order-agreement',\n\t\t\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\t\t\tstatus: divergence <= 0.15 ? 'pass' : 'warn',\n\t\t\t\t\tdetail: `GA4 recorded ${tracked} purchases; Sanity has ${actual} orders in the same 30 days.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\tdivergence > 0.15\n\t\t\t\t\t\t\t? 'A large gap usually means purchase is firing on a page some buyers never reach, firing twice, or being blocked. Worth resolving before trusting any conversion figure.'\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'purchase-order-agreement',\n\t\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not compare: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- Vercel ---------------------------------------------------------------\n\tif (!vercel) {\n\t\tchecks.push({\n\t\t\tid: 'vercel',\n\t\t\tlabel: 'Vercel Web Analytics reachable',\n\t\t\tstatus: 'skipped',\n\t\t\tdetail: 'Vercel is not configured for this site. Measurement health will have only one pageview source.',\n\t\t})\n\t} else {\n\t\ttry {\n\t\t\tconst result = await vercel.pageviews(shiftDays(today, -7), today)\n\t\t\tchecks.push({\n\t\t\t\tid: 'vercel',\n\t\t\t\tlabel: 'Vercel Web Analytics reachable',\n\t\t\t\tstatus: 'pass',\n\t\t\t\tdetail: `${result.total} pageviews in the last 7 days.`,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'vercel',\n\t\t\t\tlabel: 'Vercel Web Analytics reachable',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: (e as Error).message,\n\t\t\t\tremedy:\n\t\t\t\t\t'Web Analytics API access depends on plan tier. Without it, measurement health cannot compare two pageview sources and the panel loses its point.',\n\t\t\t})\n\t\t}\n\t}\n\n\treturn { checks, verdict: worst(checks) }\n}\n\nexport type { CheckStatus, DiagnosticCheck, DiagnosticReport } from '../reportData'\n","/**\n * The mountable Next.js API-route handler.\n *\n * Ships from this package rather than being copy-pasted into each site so the three foundries\n * cannot drift apart: a fix applied here reaches all of them on a version bump, instead of being\n * applied to one repo and forgotten in the other two. A consuming site's route is then:\n *\n * // pages/api/visitor-insights/[report].js\n * import { createVisitorInsightsHandler } from '@liiift-studio/sanity-visitor-insights/server'\n * export default createVisitorInsightsHandler({ config: mySiteConfig })\n *\n * Credentials are read from the environment inside this module and never leave the server.\n */\n\nimport {\n\tisReportName,\n\ttype DateRange,\n\ttype RangeKey,\n\ttype ReportEnvelope,\n\ttype SourceName,\n\ttype SourceStatus,\n} from '../types'\nimport { assertValidSiteConfig, type SiteAnalyticsConfig } from '../core/siteConfig'\nimport { coverageNotices } from '../core/cutover'\nimport { resolveRange, provisionalNotice } from '../core/ranges'\nimport { applyCors, requireStudioUser, type HandlerRequest, type HandlerResponse } from './auth'\nimport { createGa4Client, type Ga4Client } from './ga4'\nimport { createVercelClient, type VercelClient } from './vercel'\nimport { parseServiceAccountKey } from './googleAuth'\nimport { cacheKey, withCache, DEFAULT_TTL_MS } from './cache'\nimport type { SanityQueryClient } from './orders'\nimport { measurementHealth } from './reports/measurementHealth'\nimport { acquisition } from './reports/acquisition'\nimport { journey } from './reports/journey'\nimport { typefaceInterest } from './reports/typefaceInterest'\nimport { runDiagnostics } from './diagnostics'\nimport { JOURNEY_STEPS } from './reports/journey'\n\n/** Environment variables this handler reads. Names are fixed so all three sites match. */\nexport const ENV_VARS = {\n\t/** Service-account JSON (raw or base64) with Viewer on the GA4 property. */\n\tgoogleServiceAccount: 'VISITOR_INSIGHTS_GA4_SERVICE_ACCOUNT',\n\t/** Vercel API token with read access to the project. */\n\tvercelToken: 'VISITOR_INSIGHTS_VERCEL_TOKEN',\n} as const\n\n/** Options for building a handler. */\nexport interface HandlerOptions {\n\t/** This site's description of itself. */\n\tconfig: SiteAnalyticsConfig\n\t/**\n\t * Sanity client used for order counts. Supply the site's existing server-side client so this\n\t * package does not need its own Sanity credentials.\n\t */\n\tsanityClient?: SanityQueryClient\n\t/** Sanity project id used to verify Studio tokens. Defaults to `SANITY_STUDIO_PROJECT_ID`. */\n\tsanityProjectId?: string\n\t/** Cache lifetime for report responses. */\n\tcacheTtlMs?: number\n}\n\n/** Valid range keys, as an allow-list for the query parameter. */\nconst RANGE_KEYS: RangeKey[] = ['week', 'quarter', 'year']\n\n/** Read a query parameter that may arrive as a string or an array. */\nfunction param(req: HandlerRequest, name: string): string | undefined {\n\tconst raw = req.query?.[name]\n\treturn Array.isArray(raw) ? raw[0] : raw\n}\n\n/**\n * Build the API-route handler for a site.\n *\n * @param options - the site config and its Sanity client\n * @returns a Next.js Pages Router API handler\n */\nexport function createVisitorInsightsHandler(options: HandlerOptions) {\n\t// Fail at construction rather than per-request, so a misconfiguration surfaces on deploy.\n\tassertValidSiteConfig(options.config)\n\n\tconst config = options.config\n\tconst ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS\n\n\treturn async function handler(req: HandlerRequest, res: HandlerResponse): Promise<void> {\n\t\tif (applyCors(req, res, config.allowedStudioOrigins ?? [])) return\n\n\t\tif (req.method !== 'GET') {\n\t\t\tres.status(405).json({ error: 'Method not allowed' })\n\t\t\treturn\n\t\t}\n\n\t\tconst sanityProjectId = options.sanityProjectId ?? process.env.SANITY_STUDIO_PROJECT_ID ?? ''\n\t\tconst user = await requireStudioUser(req, res, sanityProjectId)\n\t\tif (!user) return\n\n\t\t// Strict allow-list. The report name selects from a fixed set and is never used to build\n\t\t// an upstream request path or to look up code dynamically.\n\t\tconst reportName = param(req, 'report')\n\t\tif (!isReportName(reportName)) {\n\t\t\tres.status(400).json({ error: 'Unknown report' })\n\t\t\treturn\n\t\t}\n\n\t\tconst rangeKey = param(req, 'range') ?? 'week'\n\t\tif (!RANGE_KEYS.includes(rangeKey as RangeKey)) {\n\t\t\tres.status(400).json({ error: 'Unknown range' })\n\t\t\treturn\n\t\t}\n\n\t\t// Ranges are anchored to the GA4 property's timezone so all three sources agree on\n\t\t// where a day begins. Falls back to UTC only when GA4 is not configured at all.\n\t\tconst timezone = config.ga4?.timezone ?? 'UTC'\n\t\tconst range = resolveRange(rangeKey as RangeKey, timezone)\n\n\t\tconst sources: Partial<Record<SourceName, SourceStatus>> = {}\n\n\t\tconst serviceAccount = parseServiceAccountKey(process.env[ENV_VARS.googleServiceAccount])\n\t\tlet ga4: Ga4Client | null = null\n\t\tif (!config.ga4) {\n\t\t\tsources.ga4 = { status: 'unconfigured' }\n\t\t} else if (!serviceAccount) {\n\t\t\tsources.ga4 = { status: 'error', message: 'Service account missing or unparseable' }\n\t\t} else {\n\t\t\tga4 = createGa4Client(config.ga4.propertyId, serviceAccount)\n\t\t\tsources.ga4 = { status: 'ok' }\n\t\t}\n\n\t\tconst vercelToken = process.env[ENV_VARS.vercelToken]\n\t\tlet vercel: VercelClient | null = null\n\t\tif (!config.vercel) {\n\t\t\tsources.vercel = { status: 'unconfigured' }\n\t\t} else if (!vercelToken) {\n\t\t\tsources.vercel = { status: 'error', message: 'Vercel token missing' }\n\t\t} else {\n\t\t\tvercel = createVercelClient(config.vercel.projectId, vercelToken, config.vercel.teamId)\n\t\t\tsources.vercel = { status: 'ok' }\n\t\t}\n\n\t\tconst sanity = options.sanityClient ?? null\n\t\tsources.sanity = sanity ? { status: 'ok' } : { status: 'unconfigured' }\n\n\t\ttry {\n\t\t\tconst key = cacheKey(['vi', config.siteId, reportName, range.key, range.start, range.end])\n\n\t\t\tconst envelope = await withCache<ReportEnvelope<unknown>>(key, ttl, async () => {\n\t\t\t\tconst notices: string[] = []\n\n\t\t\t\tconst provisional = provisionalNotice(range)\n\t\t\t\tif (provisional) notices.push(provisional)\n\n\t\t\t\tconst data = await runReport(reportName, { config, range, ga4, vercel, sanity, notices })\n\n\t\t\t\treturn { report: reportName, range, sources, notices, data }\n\t\t\t})\n\n\t\t\tres.status(200).json(envelope)\n\t\t} catch (e) {\n\t\t\t// Upstream messages can echo request detail, so only a generic message crosses the wire.\n\t\t\tconsole.error(`Visitor insights: report \"${reportName}\" failed:`, (e as Error).message)\n\t\t\tres.status(502).json({ error: 'Report failed', report: reportName })\n\t\t}\n\t}\n}\n\n/** Arguments shared by every report. */\ninterface RunContext {\n\tconfig: SiteAnalyticsConfig\n\trange: DateRange\n\tga4: Ga4Client | null\n\tvercel: VercelClient | null\n\tsanity: SanityQueryClient | null\n\tnotices: string[]\n}\n\n/** Dispatch to a report by name. A plain switch, so the set of reachable code paths is closed. */\nasync function runReport(report: string, ctx: RunContext): Promise<unknown> {\n\tconst { config, range, ga4, vercel, sanity, notices } = ctx\n\n\tswitch (report) {\n\t\tcase 'measurement-health':\n\t\t\treturn measurementHealth({ config, range, ga4, vercel, sanity })\n\n\t\tcase 'acquisition':\n\t\t\tif (!ga4) throw new Error('GA4 is required for the acquisition report')\n\t\t\treturn acquisition(ga4, range)\n\n\t\tcase 'journey': {\n\t\t\tif (!ga4) throw new Error('GA4 is required for the journey report')\n\t\t\tnotices.push(...coverageNotices(config.eventCutovers, JOURNEY_STEPS.map((s) => s.event), range))\n\t\t\treturn journey(config, ga4, range)\n\t\t}\n\n\t\tcase 'diagnostics':\n\t\t\t// Deliberately tolerates missing sources: its whole job is to report what is absent.\n\t\t\treturn runDiagnostics({ config, ga4, vercel, sanity })\n\n\t\tcase 'typeface-interest':\n\t\t\tif (!ga4) throw new Error('GA4 is required for the typeface-interest report')\n\t\t\treturn typefaceInterest({ config, range, ga4, sanity })\n\n\t\tdefault:\n\t\t\tthrow new Error(`Unhandled report: ${report}`)\n\t}\n}\n"],"mappings":"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","names":["header"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/types.ts","../src/core/siteConfig.ts","../src/core/cutover.ts","../src/core/ranges.ts","../src/server/auth.ts","../src/server/googleAuth.ts","../src/server/ga4.ts","../src/server/vercel.ts","../src/server/cache.ts","../src/server/orders.ts","../src/server/reports/measurementHealth.ts","../src/server/reports/acquisition.ts","../src/server/reports/journey.ts","../src/server/reports/typefaceInterest.ts","../src/server/diagnostics.ts","../src/server/createHandler.ts"],"sourcesContent":["/**\n * Server entry point — Node only.\n *\n * Nothing here may be imported from the Studio entry (`src/index.ts`). This module reaches\n * node:crypto and reads service-account credentials from the environment; keeping it behind its\n * own export subpath is what guarantees a bundler cannot pull it into a Studio browser bundle.\n */\n\nexport { createVisitorInsightsHandler, ENV_VARS, type HandlerOptions } from './server/createHandler'\nexport type { HandlerRequest, HandlerResponse, StudioUser } from './server/auth'\nexport { verifyStudioRequest, requireStudioUser, applyCors } from './server/auth'\nexport type { SanityQueryClient } from './server/orders'\nexport { clearCache } from './server/cache'\n\n// Report result shapes, so a consuming site can type a custom surface against them.\nexport type { MeasurementHealthData } from './server/reports/measurementHealth'\nexport type { AcquisitionData, SourceRow } from './server/reports/acquisition'\nexport type { JourneyData, JourneyStep, ExitPage } from './server/reports/journey'\nexport type { TypefaceInterestData, TypefaceInterestRow } from './server/reports/typefaceInterest'\nexport { JOURNEY_STEPS } from './server/reports/journey'\nexport { DESIGN_INDUSTRY_SOURCES } from './server/reports/acquisition'\nexport { runDiagnostics, type DiagnosticsInput } from './server/diagnostics'\n\n// Client constructors. Needed to call runDiagnostics headlessly, which the README documents —\n// without these exported that example could not actually be written.\nexport { createGa4Client, type Ga4Client, type Ga4Report, type Ga4ReportRequest } from './server/ga4'\nexport { createVercelClient, type VercelClient, type VercelPageviews } from './server/vercel'\nexport { parseServiceAccountKey, type ServiceAccountKey } from './server/googleAuth'\nexport type { CheckStatus, DiagnosticCheck, DiagnosticReport } from './reportData'\n\n// Shared contract, re-exported so the server entry is self-sufficient.\nexport * from './types'\nexport * from './core/cutover'\nexport * from './core/siteConfig'\nexport * from './core/ranges'\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 * 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 * 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 * 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","/**\n * Request authentication and CORS for the report handler.\n *\n * The Studio forwards its own Sanity session token and this verifies it against Sanity. A shared\n * secret was the obvious alternative and is the wrong one: the Studio bundle is served publicly, so\n * any secret compiled into it is extractable, which moves the bar from \"know the URL\" to \"open\n * devtools\". Verifying a session token instead proves the caller is a logged-in project user, and\n * gives a real identity to log rather than an anonymous caller who read a constant.\n *\n * This mirrors the pattern already in production on Darden's order-action endpoints.\n */\n\n/** Minimal shape this module needs from a Next.js API request. */\nexport interface HandlerRequest {\n\tmethod?: string\n\turl?: string\n\theaders: Record<string, string | string[] | undefined>\n\tquery?: Record<string, string | string[] | undefined>\n}\n\n/** Minimal shape this module needs from a Next.js API response. */\nexport interface HandlerResponse {\n\tsetHeader(name: string, value: string): void\n\tstatus(code: number): HandlerResponse\n\tjson(body: unknown): void\n\tend(): void\n}\n\n/** A verified Sanity Studio user. */\nexport interface StudioUser {\n\tid: string\n\tname?: string\n\temail?: string\n\troles?: Array<{ name: string }>\n}\n\n/** Outcome of verifying a request's Studio token. */\nexport type VerifyResult =\n\t| { ok: true; user: StudioUser }\n\t| { ok: false; reason: string }\n\n/** Read a header that may arrive as a string or an array. */\nfunction header(req: HandlerRequest, name: string): string | undefined {\n\tconst raw = req.headers[name] ?? req.headers[name.toLowerCase()]\n\treturn Array.isArray(raw) ? raw[0] : raw\n}\n\n/**\n * Verify that a request carries a valid Sanity user token for this project.\n *\n * @param req - the incoming request\n * @param sanityProjectId - project the token must belong to\n * @returns ok with the resolved user, or a reason suitable for logging but never for the response\n */\nexport async function verifyStudioRequest(req: HandlerRequest, sanityProjectId: string): Promise<VerifyResult> {\n\tconst authorization = header(req, 'authorization') ?? ''\n\tconst token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : null\n\n\tif (!token) return { ok: false, reason: 'no-token' }\n\tif (!sanityProjectId) {\n\t\t// Fail closed rather than silently authorising everything if the env is misconfigured.\n\t\tconsole.error('Visitor insights: Sanity project id not configured — cannot verify Studio requests')\n\t\treturn { ok: false, reason: 'not-configured' }\n\t}\n\n\ttry {\n\t\tconst response = await fetch(`https://${sanityProjectId}.api.sanity.io/v2021-06-07/users/me`, {\n\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t})\n\t\tif (!response.ok) return { ok: false, reason: `sanity-${response.status}` }\n\n\t\tconst user = (await response.json()) as StudioUser | null\n\t\t// Sanity answers 200 with a null id for an unauthenticated request rather than a 401.\n\t\tif (!user || !user.id) return { ok: false, reason: 'no-user' }\n\n\t\treturn { ok: true, user }\n\t} catch (e) {\n\t\tconsole.error('Visitor insights: Studio token verification failed:', (e as Error).message)\n\t\treturn { ok: false, reason: 'verify-error' }\n\t}\n}\n\n/**\n * Apply CORS headers for a Studio-originated request and answer the preflight.\n *\n * Echoes one allow-listed origin rather than sending `*`. A wildcard is acceptable for public HTML\n * but not for an endpoint that takes an Authorization header and returns business data.\n *\n * @param req - the incoming request\n * @param res - the response to decorate\n * @param allowedOrigins - exact origins permitted to call cross-origin\n * @returns true when the request was a preflight and is now fully answered\n */\nexport function applyCors(req: HandlerRequest, res: HandlerResponse, allowedOrigins: readonly string[]): boolean {\n\tconst origin = header(req, 'origin')\n\n\tif (origin && allowedOrigins.includes(origin)) {\n\t\tres.setHeader('Access-Control-Allow-Origin', origin)\n\t\t// The response varies by origin, so caches must not serve one origin's response to another.\n\t\tres.setHeader('Vary', 'Origin')\n\t\tres.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')\n\t\tres.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')\n\t\tres.setHeader('Access-Control-Max-Age', '600')\n\t}\n\n\tif (req.method === 'OPTIONS') {\n\t\t// A disallowed origin gets 204 without allow headers; the browser rejects it on its own,\n\t\t// so there is no need to reveal whether the origin is known.\n\t\tres.status(204).end()\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n/**\n * Guard a report endpoint. Responds 401 and returns null when the caller cannot be verified.\n *\n * @returns the verified user, or null when the request has already been answered with a 401\n */\nexport async function requireStudioUser(\n\treq: HandlerRequest,\n\tres: HandlerResponse,\n\tsanityProjectId: string,\n): Promise<StudioUser | null> {\n\tconst result = await verifyStudioRequest(req, sanityProjectId)\n\n\tif (result.ok) return result.user\n\n\tconsole.error(`Visitor insights: rejected unauthenticated request (${result.reason}) on ${req.url ?? 'unknown'}`)\n\tres.status(401).json({ error: 'Not authorised. Sign in to the Studio and try again.' })\n\treturn null\n}\n","/**\n * Service-account access tokens for the GA4 Data API.\n *\n * Implemented directly against Google's OAuth2 token endpoint rather than through `googleapis`,\n * which is a very large dependency to pull into a package that only needs one grant type. Signing\n * a JWT with node:crypto keeps the dependency surface at zero and removes any risk of a\n * credential-bearing SDK being reachable from the Studio entry point.\n */\n\nimport { createSign } from 'node:crypto'\n\n/** Read-only scope for the GA4 Data API — the least privilege this package needs. */\nconst ANALYTICS_READONLY_SCOPE = 'https://www.googleapis.com/auth/analytics.readonly'\n\n/** Google's OAuth2 token endpoint for the JWT bearer grant. */\nconst TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'\n\n/** Seconds an assertion stays valid. Google caps this at one hour. */\nconst ASSERTION_TTL_SECONDS = 3600\n\n/** Refresh this many seconds before actual expiry, so a token never expires mid-flight. */\nconst REFRESH_MARGIN_SECONDS = 60\n\n/** The parts of a service-account JSON key this module uses. */\nexport interface ServiceAccountKey {\n\tclient_email: string\n\tprivate_key: string\n}\n\ninterface CachedToken {\n\ttoken: string\n\texpiresAt: number\n}\n\n/** Access tokens are cached per service account for their lifetime. */\nconst tokenCache = new Map<string, CachedToken>()\n\n/** Base64url without padding, as JWT requires. */\nfunction base64url(input: string | Buffer): string {\n\treturn Buffer.from(input).toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\n/**\n * Parse a service-account key from an environment variable.\n *\n * Accepts either raw JSON or base64-encoded JSON, because multi-line JSON with embedded newlines\n * is awkward to set in some dashboards and gets mangled often enough to be worth tolerating both.\n *\n * @param raw - the environment variable's value\n * @returns the parsed key, or null when unset or unparseable\n */\nexport function parseServiceAccountKey(raw: string | undefined): ServiceAccountKey | null {\n\tif (!raw) return null\n\n\tlet text = raw.trim()\n\n\t// Base64 payloads contain no braces; JSON always starts with one.\n\tif (!text.startsWith('{')) {\n\t\ttry {\n\t\t\ttext = Buffer.from(text, 'base64').toString('utf8')\n\t\t} catch {\n\t\t\treturn null\n\t\t}\n\t}\n\n\ttry {\n\t\tconst parsed = JSON.parse(text) as Partial<ServiceAccountKey>\n\t\tif (!parsed.client_email || !parsed.private_key) return null\n\n\t\treturn {\n\t\t\tclient_email: parsed.client_email,\n\t\t\t// Escaped newlines survive most env-var round trips; real newlines do not always.\n\t\t\tprivate_key: parsed.private_key.replace(/\\\\n/g, '\\n'),\n\t\t}\n\t} catch {\n\t\treturn null\n\t}\n}\n\n/**\n * Get an access token for the GA4 Data API, minting one only when the cached token is near expiry.\n *\n * @param key - the service-account key\n * @returns a bearer token\n * @throws when Google rejects the assertion\n */\nexport async function getAccessToken(key: ServiceAccountKey): Promise<string> {\n\tconst now = Math.floor(Date.now() / 1000)\n\tconst cached = tokenCache.get(key.client_email)\n\n\tif (cached && cached.expiresAt - REFRESH_MARGIN_SECONDS > now) {\n\t\treturn cached.token\n\t}\n\n\tconst header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))\n\tconst claims = base64url(\n\t\tJSON.stringify({\n\t\t\tiss: key.client_email,\n\t\t\tscope: ANALYTICS_READONLY_SCOPE,\n\t\t\taud: TOKEN_ENDPOINT,\n\t\t\texp: now + ASSERTION_TTL_SECONDS,\n\t\t\tiat: now,\n\t\t}),\n\t)\n\n\tconst signer = createSign('RSA-SHA256')\n\tsigner.update(`${header}.${claims}`)\n\tconst signature = base64url(signer.sign(key.private_key))\n\tconst assertion = `${header}.${claims}.${signature}`\n\n\tconst response = await fetch(TOKEN_ENDPOINT, {\n\t\tmethod: 'POST',\n\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\tbody: new URLSearchParams({\n\t\t\tgrant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n\t\t\tassertion,\n\t\t}),\n\t})\n\n\tif (!response.ok) {\n\t\t// Deliberately does not include the response body, which can echo parts of the assertion.\n\t\tthrow new Error(`Google token exchange failed with ${response.status}`)\n\t}\n\n\tconst body = (await response.json()) as { access_token?: string; expires_in?: number }\n\tif (!body.access_token) throw new Error('Google token exchange returned no access token')\n\n\ttokenCache.set(key.client_email, {\n\t\ttoken: body.access_token,\n\t\texpiresAt: now + (body.expires_in ?? ASSERTION_TTL_SECONDS),\n\t})\n\n\treturn body.access_token\n}\n\n/** Clear the token cache. Test seam. */\nexport function resetTokenCache(): void {\n\ttokenCache.clear()\n}\n","/**\n * GA4 Data API client.\n *\n * Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:\n * it is an alpha surface with its own stricter quota, and it returns step-conversion marginals\n * rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was\n * never measured. The journey report approximates instead, and says so.\n */\n\nimport { getAccessToken, type ServiceAccountKey } from './googleAuth'\n\nconst DATA_API_BASE = 'https://analyticsdata.googleapis.com/v1beta'\n\n/** A GA4 report request, narrowed to the fields this package sets. */\nexport interface Ga4ReportRequest {\n\tdimensions?: Array<{ name: string }>\n\tmetrics?: Array<{ name: string }>\n\tdateRanges: Array<{ startDate: string; endDate: string }>\n\tdimensionFilter?: unknown\n\torderBys?: unknown\n\tlimit?: number\n\tkeepEmptyRows?: boolean\n}\n\n/** A parsed report row: dimension values and metric values, positionally aligned to the request. */\nexport interface Ga4Row {\n\tdimensions: string[]\n\tmetrics: number[]\n}\n\n/** A parsed GA4 report. */\nexport interface Ga4Report {\n\trows: Ga4Row[]\n\t/** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */\n\tthresholded: boolean\n\t/** True when GA4 answered from a sample rather than the full data set. */\n\tsampled: boolean\n\t/** Total row count GA4 reports, which may exceed rows returned when a limit applied. */\n\trowCount: number\n\t/**\n\t * The property's configured timezone, as GA4 reports it. Worth capturing because every range\n\t * is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.\n\t */\n\ttimeZone?: string\n}\n\n/** Raw Data API response shape, narrowed to what is read here. */\ninterface RawReport {\n\trows?: Array<{\n\t\tdimensionValues?: Array<{ value?: string }>\n\t\tmetricValues?: Array<{ value?: string }>\n\t}>\n\trowCount?: number\n\tmetadata?: { subjectToThresholding?: boolean; samplingMetadatas?: unknown[]; timeZone?: string }\n\tpropertyQuota?: unknown\n}\n\n/**\n * Coerce a GA4 metric cell to a number.\n *\n * The Data API returns every value as a string, and returns an empty string for suppressed cells.\n * `Number('')` is 0, which would turn a withheld row into a real-looking zero — so empty is\n * treated as NaN and filtered by the caller rather than silently becoming a data point.\n */\nfunction toMetricNumber(raw: string | undefined): number {\n\tif (raw === undefined || raw === '') return Number.NaN\n\tconst parsed = Number(raw)\n\treturn Number.isFinite(parsed) ? parsed : Number.NaN\n}\n\n/** Parse a raw Data API report into the shape the reports consume. */\nfunction parseReport(raw: RawReport): Ga4Report {\n\tconst rows: Ga4Row[] = (raw.rows ?? []).map((row) => ({\n\t\tdimensions: (row.dimensionValues ?? []).map((d) => d.value ?? ''),\n\t\tmetrics: (row.metricValues ?? []).map((m) => toMetricNumber(m.value)),\n\t}))\n\n\treturn {\n\t\trows,\n\t\tthresholded: raw.metadata?.subjectToThresholding === true,\n\t\tsampled: Array.isArray(raw.metadata?.samplingMetadatas) && raw.metadata.samplingMetadatas.length > 0,\n\t\trowCount: raw.rowCount ?? rows.length,\n\t\ttimeZone: raw.metadata?.timeZone,\n\t}\n}\n\n/** A GA4 client bound to one property. */\nexport interface Ga4Client {\n\trunReport(request: Ga4ReportRequest): Promise<Ga4Report>\n\tbatchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>\n}\n\n/**\n * Build a GA4 client for one property.\n *\n * @param propertyId - numeric GA4 property id\n * @param key - service-account key with Viewer on that property\n */\nexport function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client {\n\tasync function post<T>(path: string, body: unknown): Promise<T> {\n\t\tconst token = await getAccessToken(key)\n\n\t\tconst response = await fetch(`${DATA_API_BASE}/properties/${propertyId}:${path}`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${token}`,\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t},\n\t\t\tbody: JSON.stringify(body),\n\t\t})\n\n\t\tif (!response.ok) {\n\t\t\t// GA4 error bodies can echo the request; keep only the status for the caller's log.\n\t\t\tthrow new Error(`GA4 ${path} failed with ${response.status}`)\n\t\t}\n\n\t\treturn (await response.json()) as T\n\t}\n\n\treturn {\n\t\tasync runReport(request) {\n\t\t\tconst raw = await post<RawReport>('runReport', request)\n\t\t\treturn parseReport(raw)\n\t\t},\n\n\t\t/**\n\t\t * Run several reports in one call. Preferred over parallel runReport calls: it is one\n\t\t * quota-charged request and one round trip rather than N of each.\n\t\t */\n\t\tasync batchRunReports(requests) {\n\t\t\tif (requests.length === 0) return []\n\n\t\t\tconst raw = await post<{ reports?: RawReport[] }>('batchRunReports', { requests })\n\t\t\treturn (raw.reports ?? []).map(parseReport)\n\t\t},\n\t}\n}\n\n/** Build a dimension filter matching a single event name. */\nexport function eventNameFilter(eventName: string): unknown {\n\treturn {\n\t\tfilter: {\n\t\t\tfieldName: 'eventName',\n\t\t\tstringFilter: { matchType: 'EXACT', value: eventName },\n\t\t},\n\t}\n}\n\n/** Sum a report's first metric across all rows, ignoring suppressed cells. */\nexport function sumFirstMetric(report: Ga4Report): number {\n\treturn report.rows.reduce((total, row) => {\n\t\tconst value = row.metrics[0]\n\t\treturn value !== undefined && Number.isFinite(value) ? total + value : total\n\t}, 0)\n}\n","/**\n * Vercel Web Analytics client.\n *\n * Vercel's Web Analytics read API is not a stable, versioned public surface the way the GA4 Data\n * API is, and access depends on plan tier. This client is therefore written to degrade rather than\n * throw: a missing token, an unavailable plan, or a changed endpoint all surface as an unconfigured\n * or errored source in the report envelope, so the Measurement Health panel can say \"Vercel did not\n * answer\" instead of implying the site had no traffic.\n *\n * Vercel is counted here purely as a second, cookieless pageview measurement. It is never treated\n * as ground truth, and never subtracted from a GA4 session count — those are different units.\n */\n\nconst API_BASE = 'https://api.vercel.com'\n\n/** Daily pageview counts, keyed by ISO date. */\nexport interface VercelPageviews {\n\tbyDate: Record<string, number>\n\ttotal: number\n}\n\n/** A Vercel client bound to one project. */\nexport interface VercelClient {\n\tpageviews(start: string, end: string): Promise<VercelPageviews>\n}\n\n/**\n * Build a Vercel Web Analytics client.\n *\n * @param projectId - the Vercel project id\n * @param token - a Vercel API token with read access to that project\n * @param teamId - team scope, required when the project belongs to a team\n */\nexport function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient {\n\treturn {\n\t\tasync pageviews(start, end) {\n\t\t\tconst params = new URLSearchParams({\n\t\t\t\tprojectId,\n\t\t\t\tsince: `${start}T00:00:00.000Z`,\n\t\t\t\tuntil: `${end}T23:59:59.999Z`,\n\t\t\t})\n\t\t\tif (teamId) params.set('teamId', teamId)\n\n\t\t\tconst response = await fetch(`${API_BASE}/v1/web-analytics/timeseries?${params.toString()}`, {\n\t\t\t\theaders: { Authorization: `Bearer ${token}` },\n\t\t\t})\n\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`Vercel Web Analytics failed with ${response.status}`)\n\t\t\t}\n\n\t\t\tconst body = (await response.json()) as { data?: Array<{ key?: string; total?: number; devices?: number }> }\n\t\t\tconst byDate: Record<string, number> = {}\n\t\t\tlet total = 0\n\n\t\t\tfor (const point of body.data ?? []) {\n\t\t\t\tif (!point.key) continue\n\t\t\t\t// `key` is a timestamp or date string depending on granularity; normalise to a date.\n\t\t\t\tconst date = point.key.slice(0, 10)\n\t\t\t\tconst count = typeof point.total === 'number' ? point.total : 0\n\t\t\t\tbyDate[date] = (byDate[date] ?? 0) + count\n\t\t\t\ttotal += count\n\t\t\t}\n\n\t\t\treturn { byDate, total }\n\t\t},\n\t}\n}\n","/**\n * A small in-memory TTL cache for report responses.\n *\n * Without this, every panel render and every range toggle is a live fan-out to GA4, Vercel and\n * Sanity. GA4's standard quota is finite per property per day and shared with anything else\n * querying it, so an editor idly switching ranges could exhaust it. Reports also change slowly —\n * GA4 does not finalise the last two days at all — so serving a few minutes stale costs nothing.\n *\n * Deliberately per-instance rather than a shared store: it is a quota guard and a latency\n * smoother, not a source of truth, and a cold serverless instance simply repopulates it.\n */\n\n/** How long a cached report stays fresh. */\nexport const DEFAULT_TTL_MS = 5 * 60 * 1000\n\ninterface Entry<T> {\n\tvalue: T\n\texpiresAt: number\n}\n\n/** Bound the cache so a long-lived instance cannot grow without limit. */\nconst MAX_ENTRIES = 200\n\nconst store = new Map<string, Entry<unknown>>()\n\n/** Build a stable cache key from the parts that determine a response. */\nexport function cacheKey(parts: Array<string | number | undefined>): string {\n\treturn parts.map((p) => String(p ?? '')).join('|')\n}\n\n/**\n * Read a fresh cached value, or undefined when absent or stale.\n * Stale entries are evicted on read so expiry does not depend on a sweep.\n */\nexport function getCached<T>(key: string): T | undefined {\n\tconst entry = store.get(key)\n\tif (!entry) return undefined\n\n\tif (entry.expiresAt <= Date.now()) {\n\t\tstore.delete(key)\n\t\treturn undefined\n\t}\n\n\treturn entry.value as T\n}\n\n/** Store a value with a TTL, evicting the oldest entry when full. */\nexport function setCached<T>(key: string, value: T, ttlMs: number = DEFAULT_TTL_MS): void {\n\tif (store.size >= MAX_ENTRIES && !store.has(key)) {\n\t\t// Map preserves insertion order, so the first key is the oldest.\n\t\tconst oldest = store.keys().next().value\n\t\tif (oldest !== undefined) store.delete(oldest)\n\t}\n\n\tstore.set(key, { value, expiresAt: Date.now() + ttlMs })\n}\n\n/**\n * Return a cached value or compute, cache and return it.\n * Concurrent callers may both compute on a cold key; that is acceptable here and avoids the\n * complexity of an in-flight promise registry for a cache this small.\n */\nexport async function withCache<T>(key: string, ttlMs: number, compute: () => Promise<T>): Promise<T> {\n\tconst hit = getCached<T>(key)\n\tif (hit !== undefined) return hit\n\n\tconst value = await compute()\n\tsetCached(key, value, ttlMs)\n\treturn value\n}\n\n/** Empty the cache. Test seam, and useful after a config change. */\nexport function clearCache(): void {\n\tstore.clear()\n}\n","/**\n * Order counts from Sanity, used only as a conversion anchor.\n *\n * Orders on these sites carry customer names, emails and postal addresses. Joining that to\n * behavioural analytics would turn aggregate statistics into personal-data processing and put the\n * GA4 property at risk under Google's terms, so no query in this file may project a PII field.\n * The projections below are allow-lists, not conveniences: every GROQ query here names the exact\n * fields it returns, and none of them is a customer identifier.\n *\n * Revenue is likewise absent. This package reports behaviour; the sales portal reports sales.\n */\n\n/** Fields safe to read from an order. Nothing here identifies a person. */\nconst SAFE_ORDER_PROJECTION = '{ _createdAt, orderStatus }'\n\n/** A daily count of orders. */\nexport interface OrderCounts {\n\tbyDate: Record<string, number>\n\ttotal: number\n}\n\n/** Orders attributable to a typeface, for the interest report. */\nexport interface TypefaceOrderCounts {\n\t/** Typeface title to order count. */\n\tbyTypeface: Record<string, number>\n\ttotal: number\n}\n\n/** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */\nexport interface SanityQueryClient {\n\tfetch<T>(query: string, params?: Record<string, unknown>): Promise<T>\n}\n\n/**\n * Build a GROQ filter for orders in a date range.\n * `excludeFilter` lets a site drop non-typeface orders, e.g. merch-only orders on Darden, which\n * would otherwise inflate a family's apparent purchase count.\n */\nfunction orderFilter(documentType: string, excludeFilter?: string): string {\n\tconst clauses = [\n\t\t`_type == $documentType`,\n\t\t`_createdAt >= $start`,\n\t\t`_createdAt <= $end`,\n\t]\n\tif (excludeFilter) clauses.push(`(${excludeFilter})`)\n\treturn clauses.join(' && ')\n}\n\n/**\n * Count orders per day across a range.\n *\n * Bounds are passed as full timestamps because `_createdAt` is a UTC datetime; comparing it to a\n * bare date would silently drop the last day's orders.\n *\n * @param client - a Sanity client\n * @param documentType - the site's order document type\n * @param start - inclusive ISO date\n * @param end - inclusive ISO date\n * @param excludeFilter - optional GROQ clause excluding non-typeface orders\n */\nexport async function countOrders(\n\tclient: SanityQueryClient,\n\tdocumentType: string,\n\tstart: string,\n\tend: string,\n\texcludeFilter?: string,\n): Promise<OrderCounts> {\n\tconst query = `*[${orderFilter(documentType, excludeFilter)}]${SAFE_ORDER_PROJECTION}`\n\n\tconst orders = await client.fetch<Array<{ _createdAt: string }>>(query, {\n\t\tdocumentType,\n\t\tstart: `${start}T00:00:00Z`,\n\t\tend: `${end}T23:59:59Z`,\n\t})\n\n\tconst byDate: Record<string, number> = {}\n\tfor (const order of orders) {\n\t\tconst date = order._createdAt.slice(0, 10)\n\t\tbyDate[date] = (byDate[date] ?? 0) + 1\n\t}\n\n\treturn { byDate, total: orders.length }\n}\n\n/**\n * Count orders per typeface across a range.\n *\n * Returns an empty result when the site has no usable typeface field on orders, rather than\n * guessing — an absent join is reported as unavailable upstream, not as zero purchases.\n *\n * @param typefacesField - the order field holding typeface references, or null when absent\n */\nexport async function countOrdersByTypeface(\n\tclient: SanityQueryClient,\n\tdocumentType: string,\n\ttypefacesField: string | null,\n\tstart: string,\n\tend: string,\n\texcludeFilter?: string,\n): Promise<TypefaceOrderCounts | null> {\n\tif (!typefacesField) return null\n\n\t// Dereferences only the typeface title — never the order's customer fields.\n\tconst query = `*[${orderFilter(documentType, excludeFilter)}]{\n\t\t\"typefaces\": ${typefacesField}[]->{ title }\n\t}`\n\n\tconst orders = await client.fetch<Array<{ typefaces?: Array<{ title?: string } | null> | null }>>(query, {\n\t\tdocumentType,\n\t\tstart: `${start}T00:00:00Z`,\n\t\tend: `${end}T23:59:59Z`,\n\t})\n\n\tconst byTypeface: Record<string, number> = {}\n\tlet total = 0\n\n\tfor (const order of orders) {\n\t\tfor (const typeface of order.typefaces ?? []) {\n\t\t\tconst title = typeface?.title\n\t\t\tif (!title) continue\n\t\t\tbyTypeface[title] = (byTypeface[title] ?? 0) + 1\n\t\t\ttotal += 1\n\t\t}\n\t}\n\n\treturn { byTypeface, total }\n}\n","/**\n * Measurement Health — \"what are we missing?\", answered honestly.\n *\n * This panel began life as a \"coverage gap\" that subtracted Vercel pageviews from GA4 sessions and\n * attributed the difference to consent, ad-blockers and bots. That is not a valid subtraction:\n * sessions and pageviews are different units, so the difference is dominated by the unit mismatch\n * rather than by anything missing. Worse, GA4 exposes no signal that separates consent-denied from\n * blocked from bot traffic, so the attribution would have been a guess rendered with the authority\n * of a measurement.\n *\n * What this reports instead:\n * - GA4 pageviews against Vercel pageviews, the same unit on both sides\n * - GA4 sessions and orders alongside as context, never subtracted\n * - one residual, labelled unexplained, with the causes we can actually measure broken out\n *\n * The only cause we can measure is consent: a `consent_granted` event makes the acceptance rate a\n * real number rather than an inference. Until that event is instrumented, the residual stays whole\n * and the panel says why.\n */\n\nimport type { MeasurementHealthData } from '../../reportData'\nimport type { DateRange, MetricValue } from '../../types'\nimport { ok, unavailable } from '../../types'\nimport type { SiteAnalyticsConfig } from '../../core/siteConfig'\nimport { coverageForRange } from '../../core/cutover'\nimport { sumFirstMetric, type Ga4Client } from '../ga4'\nimport type { VercelClient } from '../vercel'\nimport { countOrders, type SanityQueryClient } from '../orders'\n\n/** The event whose presence turns the consent share from a guess into a measurement. */\nconst CONSENT_EVENT = 'consent_granted'\n\n\n/** Inputs for the measurement-health report. */\nexport interface MeasurementHealthInput {\n\tconfig: SiteAnalyticsConfig\n\trange: DateRange\n\tga4: Ga4Client | null\n\tvercel: VercelClient | null\n\tsanity: SanityQueryClient | null\n}\n\n/** Build the plain-language reading shown beneath the figures. */\nfunction interpret(ga4Views: MetricValue, vercelViews: MetricValue, shortfall: number | null, consent: MetricValue): string {\n\tif (ga4Views.status === 'unavailable' && vercelViews.status === 'unavailable') {\n\t\treturn 'Neither source answered, so nothing can be said about coverage for this range.'\n\t}\n\tif (shortfall === null) {\n\t\treturn 'Only one pageview source answered, so the two cannot be compared for this range.'\n\t}\n\n\tconst percent = Math.round(Math.abs(shortfall) * 100)\n\n\tif (Math.abs(shortfall) < 0.05) {\n\t\treturn `GA4 and Vercel agree to within ${percent}% on pageviews. Nothing here suggests a measurement problem.`\n\t}\n\n\tconst direction = shortfall > 0 ? 'fewer' : 'more'\n\tconst base = `GA4 recorded ${percent}% ${direction} pageviews than Vercel.`\n\n\tif (consent.status === 'unavailable') {\n\t\treturn `${base} How much of that is consent refusal cannot be measured until the consent_granted event is instrumented, so the difference is currently unexplained rather than attributed.`\n\t}\n\n\tconst consentPercent = Math.round((1 - consent.value / 100) * 100)\n\treturn `${base} Around ${consentPercent}% of sessions did not grant analytics consent, which accounts for part of it. The remainder is unexplained — ad-blocking and bot filtering are plausible but are not separately measurable.`\n}\n\n/**\n * Run the measurement-health report.\n *\n * Each source is queried independently and a failure in one degrades only its own figures, so the\n * panel can always say which source did not answer rather than showing an unexplained blank.\n */\nexport async function measurementHealth(input: MeasurementHealthInput): Promise<MeasurementHealthData> {\n\tconst { config, range, ga4, vercel, sanity } = input\n\n\tlet ga4Pageviews: MetricValue = unavailable('source_error', 'GA4 not configured')\n\tlet ga4Sessions: MetricValue = unavailable('source_error', 'GA4 not configured')\n\tlet consentRate: MetricValue = unavailable('not_instrumented')\n\n\tif (ga4) {\n\t\ttry {\n\t\t\t// One batched call rather than three separate quota-charged requests.\n\t\t\tconst [views, sessions, consent] = await ga4.batchRunReports([\n\t\t\t\t{\n\t\t\t\t\tmetrics: [{ name: 'screenPageViews' }],\n\t\t\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tmetrics: [{ name: 'sessions' }],\n\t\t\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\t\tdimensions: [{ name: 'eventName' }],\n\t\t\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\t\t\tdimensionFilter: {\n\t\t\t\t\t\tfilter: {\n\t\t\t\t\t\t\tfieldName: 'eventName',\n\t\t\t\t\t\t\tstringFilter: { matchType: 'EXACT', value: CONSENT_EVENT },\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t])\n\n\t\t\tif (views) ga4Pageviews = ok(sumFirstMetric(views))\n\t\t\tif (sessions) ga4Sessions = ok(sumFirstMetric(sessions))\n\n\t\t\tconst consentCoverage = coverageForRange(config.eventCutovers, CONSENT_EVENT, range)\n\t\t\tif (consentCoverage.status === 'full' && consent && ga4Sessions.status === 'ok' && ga4Sessions.value > 0) {\n\t\t\t\t// Expressed as a percentage of sessions, capped since one session can fire it twice.\n\t\t\t\tconst rate = Math.min(100, (sumFirstMetric(consent) / ga4Sessions.value) * 100)\n\t\t\t\tconsentRate = ok(Math.round(rate * 10) / 10)\n\t\t\t} else if (consentCoverage.status === 'partial') {\n\t\t\t\tconsentRate = unavailable('before_cutover', `Instrumented from ${consentCoverage.cutover}`)\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: GA4 measurement-health query failed:', (e as Error).message)\n\t\t\tga4Pageviews = unavailable('source_error')\n\t\t\tga4Sessions = unavailable('source_error')\n\t\t}\n\t}\n\n\tlet vercelPageviews: MetricValue = unavailable('source_error', 'Vercel not configured')\n\tif (vercel) {\n\t\ttry {\n\t\t\tconst result = await vercel.pageviews(range.start, range.end)\n\t\t\tvercelPageviews = ok(result.total)\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: Vercel query failed:', (e as Error).message)\n\t\t\tvercelPageviews = unavailable('source_error')\n\t\t}\n\t}\n\n\tlet orders: MetricValue = unavailable('source_error', 'Sanity not configured')\n\tif (sanity) {\n\t\ttry {\n\t\t\tconst counts = await countOrders(\n\t\t\t\tsanity,\n\t\t\t\tconfig.orders.documentType,\n\t\t\t\trange.start,\n\t\t\t\trange.end,\n\t\t\t\tconfig.orders.excludeFilter,\n\t\t\t)\n\t\t\torders = ok(counts.total)\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: order count failed:', (e as Error).message)\n\t\t\torders = unavailable('source_error')\n\t\t}\n\t}\n\n\t// Computed only when both operands are real numbers, and only between matching units.\n\tconst shortfallRatio =\n\t\tga4Pageviews.status !== 'unavailable' && vercelPageviews.status !== 'unavailable' && vercelPageviews.value > 0\n\t\t\t? (vercelPageviews.value - ga4Pageviews.value) / vercelPageviews.value\n\t\t\t: null\n\n\treturn {\n\t\tga4Pageviews,\n\t\tvercelPageviews,\n\t\tshortfallRatio,\n\t\tga4Sessions,\n\t\torders,\n\t\tconsentRate,\n\t\tinterpretation: interpret(ga4Pageviews, vercelPageviews, shortfallRatio, consentRate),\n\t}\n}\n\nexport type { MeasurementHealthData } from '../../reportData'\n","/**\n * Acquisition — where visitors come from.\n *\n * Design-industry referrers are pulled out as a named segment rather than left in a generic\n * referrer table. A visit from Fonts In Use or Typewolf is a pre-qualified, industry-literate\n * visitor and behaves nothing like average organic traffic; burying those rows among search and\n * social discards the distinction a foundry actually acts on.\n *\n * `(not set)` rows are surfaced rather than swept into \"other\". On a type-foundry site they can be\n * a large share — bookmarked visits, stripped referrers, AI crawlers — and hiding them makes the\n * table look more complete than it is.\n */\n\nimport type { AcquisitionData, SourceRow } from '../../reportData'\nimport type { DateRange } from '../../types'\nimport { sumFirstMetric, type Ga4Client } from '../ga4'\n\n/** Referrer hosts that identify a design-industry source worth tracking separately. */\nexport const DESIGN_INDUSTRY_SOURCES = [\n\t'fontsinuse.com',\n\t'typewolf.com',\n\t'typographica.org',\n\t'fonts.google.com',\n\t'behance.net',\n\t'dribbble.com',\n\t'itsnicethat.com',\n] as const\n\n\n\n/** Whether a GA4 source value is one of the unattributed buckets rather than a real referrer. */\nfunction isUnattributed(source: string): boolean {\n\tconst normalised = source.toLowerCase()\n\treturn normalised === '(not set)' || normalised === '(direct)' || normalised === '(none)' || normalised === ''\n}\n\n/** Whether a GA4 source value matches a design-industry referrer. */\nfunction isDesignIndustry(source: string): boolean {\n\tconst normalised = source.toLowerCase()\n\treturn DESIGN_INDUSTRY_SOURCES.some((known) => normalised === known || normalised.endsWith(`.${known}`))\n}\n\n/**\n * Run the acquisition report.\n *\n * @param ga4 - client for the site's property\n * @param range - the requested range\n * @param limit - maximum source rows to return\n */\nexport async function acquisition(ga4: Ga4Client, range: DateRange, limit = 25): Promise<AcquisitionData> {\n\tconst report = await ga4.runReport({\n\t\tdimensions: [{ name: 'sessionSource' }, { name: 'sessionDefaultChannelGroup' }],\n\t\tmetrics: [{ name: 'sessions' }],\n\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\torderBys: [{ metric: { metricName: 'sessions' }, desc: true }],\n\t\tlimit,\n\t})\n\n\tconst rows: SourceRow[] = report.rows.map((row) => {\n\t\tconst source = row.dimensions[0] ?? '(not set)'\n\t\tconst sessions = row.metrics[0]\n\n\t\treturn {\n\t\t\tsource,\n\t\t\tchannel: row.dimensions[1] ?? 'Unassigned',\n\t\t\tsessions: Number.isFinite(sessions) ? (sessions as number) : 0,\n\t\t\tdesignIndustry: isDesignIndustry(source),\n\t\t\tunattributed: isUnattributed(source),\n\t\t}\n\t})\n\n\tconst totalSessions = sumFirstMetric(report)\n\tconst sumWhere = (predicate: (row: SourceRow) => boolean) =>\n\t\trows.filter(predicate).reduce((total, row) => total + row.sessions, 0)\n\n\treturn {\n\t\trows,\n\t\ttotalSessions,\n\t\tdesignIndustryShare: totalSessions > 0 ? sumWhere((r) => r.designIndustry) / totalSessions : null,\n\t\tunattributedShare: totalSessions > 0 ? sumWhere((r) => r.unattributed) / totalSessions : null,\n\t\trowsWithheld: report.thresholded,\n\t}\n}\n\nexport type { AcquisitionData, SourceRow } from '../../reportData'\n","/**\n * Journey — how far visitors get, and where they stop.\n *\n * This is an ordered step funnel, not a path graph, and that is a deliberate limit rather than a\n * simplification. GA4's Data API has no path-exploration endpoint; Path Exploration is a UI-only\n * feature. What is available is per-event totals and `runFunnelReport`, an alpha surface that\n * returns step-conversion marginals — not observed sequences. Rendering ribbons from marginals\n * would assert that a given visitor went A to B to C when no such co-occurrence was ever measured.\n *\n * So each step is reported as its own honest total, adjacent drop-off is derived between steps, and\n * the response is explicitly flagged as an approximation for the UI to display.\n *\n * A step whose event is not instrumented on this site reports as unavailable, never as zero. On a\n * site missing `begin_checkout`, the cart-to-checkout drop-off is not \"100% drop-off\" — it is\n * unmeasured, and the two must not look alike.\n */\n\nimport type { JourneyData, JourneyStep, ExitPage } from '../../reportData'\nimport type { DateRange, MetricValue } from '../../types'\nimport type { SiteAnalyticsConfig } from '../../core/siteConfig'\nimport { applyCoverage, coverageForRange } from '../../core/cutover'\nimport { eventNameFilter, sumFirstMetric, type Ga4Client } from '../ga4'\n\n/** The funnel, in order. Each entry names the GA4 event that evidences the step. */\nexport const JOURNEY_STEPS = [\n\t{ key: 'landed', label: 'Landed', event: 'page_view' },\n\t{ key: 'viewed_typeface', label: 'Viewed a typeface', event: 'view_item' },\n\t{ key: 'tested', label: 'Used the type tester', event: 'tester_engaged' },\n\t{ key: 'added_to_cart', label: 'Added to cart', event: 'add_to_cart' },\n\t{ key: 'began_checkout', label: 'Began checkout', event: 'begin_checkout' },\n\t{ key: 'purchased', label: 'Purchased', event: 'purchase' },\n] as const\n\n\n\n\nconst APPROXIMATION_NOTE =\n\t'These are independent per-step totals, not tracked journeys. GA4 cannot report the actual path a ' +\n\t'visitor took, so a visitor counted at one step is not necessarily the same visitor counted at the next.'\n\n/**\n * Run the journey report.\n *\n * All step queries go in one batched call rather than one request per step, which would otherwise\n * make this the most quota-expensive panel in the tool.\n */\nexport async function journey(config: SiteAnalyticsConfig, ga4: Ga4Client, range: DateRange): Promise<JourneyData> {\n\t// Only query steps whose event could return data; skip the rest to save quota.\n\tconst coverages = JOURNEY_STEPS.map((step) => ({\n\t\tstep,\n\t\tcoverage: coverageForRange(config.eventCutovers, step.event, range),\n\t}))\n\n\tconst queryable = coverages.filter((entry) => entry.coverage.status !== 'none')\n\n\tconst reports = await ga4.batchRunReports(\n\t\tqueryable.map((entry) => ({\n\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\tdimensionFilter: eventNameFilter(entry.step.event),\n\t\t})),\n\t)\n\n\tconst countsByEvent = new Map<string, number>()\n\tqueryable.forEach((entry, index) => {\n\t\tconst report = reports[index]\n\t\tif (report) countsByEvent.set(entry.step.event, sumFirstMetric(report))\n\t})\n\n\tconst steps: JourneyStep[] = []\n\tlet previousMeasurable: number | null = null\n\n\tfor (const { step, coverage } of coverages) {\n\t\tconst raw = countsByEvent.has(step.event) ? (countsByEvent.get(step.event) as number) : null\n\t\tconst count = applyCoverage(raw, coverage)\n\n\t\tconst current = count.status === 'unavailable' ? null : count.value\n\t\tconst conversionFromPrevious =\n\t\t\tpreviousMeasurable !== null && current !== null && previousMeasurable > 0\n\t\t\t\t? current / previousMeasurable\n\t\t\t\t: null\n\n\t\tsteps.push({ key: step.key, label: step.label, event: step.event, count, conversionFromPrevious })\n\n\t\t// Only advance the baseline on a measurable step, so an unavailable rung does not\n\t\t// silently make the next step's conversion look like a collapse.\n\t\tif (current !== null) previousMeasurable = current\n\t}\n\n\tlet topExitPages: ExitPage[] = []\n\ttry {\n\t\tconst exits = await ga4.runReport({\n\t\t\tdimensions: [{ name: 'pagePath' }],\n\t\t\tmetrics: [{ name: 'exits' }],\n\t\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\t\torderBys: [{ metric: { metricName: 'exits' }, desc: true }],\n\t\t\tlimit: 10,\n\t\t})\n\n\t\ttopExitPages = exits.rows.map((row) => ({\n\t\t\tpath: row.dimensions[0] ?? '(unknown)',\n\t\t\texits: Number.isFinite(row.metrics[0]) ? (row.metrics[0] as number) : 0,\n\t\t}))\n\t} catch (e) {\n\t\t// Exit pages are supplementary; losing them should not fail the funnel.\n\t\tconsole.error('Visitor insights: exit-page query failed:', (e as Error).message)\n\t}\n\n\treturn { steps, topExitPages, approximate: true, approximationNote: APPROXIMATION_NOTE }\n}\n\nexport type { JourneyData, JourneyStep, ExitPage } from '../../reportData'\n","/**\n * Typeface interest — viewed, tested, bought, per family.\n *\n * This is the one panel GA4's own UI cannot produce, because it joins GA4 engagement to Sanity\n * order documents. It is also the panel most easily misread, so two things are stated in the\n * response rather than left to the reader:\n *\n * 1. It measures aggregate interest per family, not one person's journey. In foundry sales the\n * designer who tests a typeface is frequently not the person who later pays for it, and the\n * gap between the two can be months. A low tested-to-bought ratio is therefore not a broken\n * funnel, and the UI must not frame it as one.\n * 2. \"Tested\" counts only genuine engagement — a `tester_engaged` event fired when the visitor\n * changed something. Firing on tester open would count every default-state page load as a\n * test and make the ratio meaningless.\n */\n\nimport type { TypefaceInterestData, TypefaceInterestRow } from '../../reportData'\nimport type { DateRange, MetricValue } from '../../types'\nimport { ok, unavailable } from '../../types'\nimport type { SiteAnalyticsConfig } from '../../core/siteConfig'\nimport { coverageForRange } from '../../core/cutover'\nimport { eventNameFilter, type Ga4Client } from '../ga4'\nimport { countOrdersByTypeface, type SanityQueryClient } from '../orders'\n\n/** GA4 event evidencing a typeface page view. */\nconst VIEW_EVENT = 'view_item'\n\n/** GA4 event evidencing real type-tester engagement. */\nconst TEST_EVENT = 'tester_engaged'\n\n\n\nconst INTERPRETATION_NOTE =\n\t'Aggregate interest per family, not individual journeys. The person who tests a typeface is often ' +\n\t'not the person who buys it, and may buy months later — so a low tested-to-bought ratio is not ' +\n\t'necessarily a conversion problem.'\n\n/**\n * Read per-typeface event counts keyed by the GA4 `itemName` dimension.\n * Returns null when the event is not usable for this range, so the caller can distinguish\n * \"no data\" from \"no interest\".\n */\nasync function countsByItem(\n\tconfig: SiteAnalyticsConfig,\n\tga4: Ga4Client,\n\trange: DateRange,\n\teventName: string,\n): Promise<Map<string, number> | null> {\n\tif (coverageForRange(config.eventCutovers, eventName, range).status === 'none') return null\n\n\tconst report = await ga4.runReport({\n\t\tdimensions: [{ name: 'itemName' }],\n\t\tmetrics: [{ name: 'eventCount' }],\n\t\tdateRanges: [{ startDate: range.start, endDate: range.end }],\n\t\tdimensionFilter: eventNameFilter(eventName),\n\t\torderBys: [{ metric: { metricName: 'eventCount' }, desc: true }],\n\t\tlimit: 100,\n\t})\n\n\tconst counts = new Map<string, number>()\n\tfor (const row of report.rows) {\n\t\tconst name = row.dimensions[0]\n\t\tconst value = row.metrics[0]\n\t\tif (name && Number.isFinite(value)) counts.set(name, value as number)\n\t}\n\n\treturn counts\n}\n\n/** Inputs for the typeface-interest report. */\nexport interface TypefaceInterestInput {\n\tconfig: SiteAnalyticsConfig\n\trange: DateRange\n\tga4: Ga4Client\n\tsanity: SanityQueryClient | null\n}\n\n/** Run the typeface-interest report. */\nexport async function typefaceInterest(input: TypefaceInterestInput): Promise<TypefaceInterestData> {\n\tconst { config, range, ga4, sanity } = input\n\n\tconst [viewed, tested] = await Promise.all([\n\t\tcountsByItem(config, ga4, range, VIEW_EVENT),\n\t\tcountsByItem(config, ga4, range, TEST_EVENT),\n\t])\n\n\tlet bought: Map<string, number> | null = null\n\tif (sanity) {\n\t\ttry {\n\t\t\tconst counts = await countOrdersByTypeface(\n\t\t\t\tsanity,\n\t\t\t\tconfig.orders.documentType,\n\t\t\t\tconfig.orders.typefacesField,\n\t\t\t\trange.start,\n\t\t\t\trange.end,\n\t\t\t\tconfig.orders.excludeFilter,\n\t\t\t)\n\t\t\tif (counts) bought = new Map(Object.entries(counts.byTypeface))\n\t\t} catch (e) {\n\t\t\tconsole.error('Visitor insights: per-typeface order count failed:', (e as Error).message)\n\t\t}\n\t}\n\n\t// Union of every family seen by any source, so a family that sold but was never viewed\n\t// (or vice versa) still appears rather than being silently dropped.\n\tconst families = new Set<string>([\n\t\t...(viewed?.keys() ?? []),\n\t\t...(tested?.keys() ?? []),\n\t\t...(bought?.keys() ?? []),\n\t])\n\n\tconst metric = (counts: Map<string, number> | null, family: string, missingReason: 'not_instrumented' | 'not_applicable'): MetricValue =>\n\t\tcounts === null ? unavailable(missingReason) : ok(counts.get(family) ?? 0)\n\n\tconst rows: TypefaceInterestRow[] = [...families].map((family) => {\n\t\tconst viewedMetric = metric(viewed, family, 'not_instrumented')\n\t\tconst testedMetric = metric(tested, family, 'not_instrumented')\n\t\tconst boughtMetric = bought === null ? unavailable('not_applicable', 'Orders do not resolve to typefaces on this site') : ok(bought.get(family) ?? 0)\n\n\t\tconst testRate =\n\t\t\tviewedMetric.status !== 'unavailable' &&\n\t\t\ttestedMetric.status !== 'unavailable' &&\n\t\t\tviewedMetric.value > 0\n\t\t\t\t? testedMetric.value / viewedMetric.value\n\t\t\t\t: null\n\n\t\treturn { typeface: family, viewed: viewedMetric, tested: testedMetric, bought: boughtMetric, testRate }\n\t})\n\n\t// Most-viewed first, with unavailable views sorting last rather than as zero.\n\trows.sort((a, b) => {\n\t\tconst av = a.viewed.status === 'unavailable' ? -1 : a.viewed.value\n\t\tconst bv = b.viewed.status === 'unavailable' ? -1 : b.viewed.value\n\t\treturn bv - av\n\t})\n\n\treturn { rows, interpretationNote: INTERPRETATION_NOTE, rowsWithheld: false }\n}\n\nexport type { TypefaceInterestData, TypefaceInterestRow } from '../../reportData'\n","/**\n * Preflight diagnostics — run these the moment real credentials exist.\n *\n * Every panel in this package is only as trustworthy as the property behind it, and the ways a GA4\n * property can quietly produce wrong numbers are not visible from the numbers themselves: retention\n * silently truncating a year range, a timezone that disagrees with the site config, an event the\n * config claims is live that has never actually fired, a `purchase` with no `transaction_id` to\n * join against.\n *\n * Each check is written to distinguish \"measured and fine\" from \"measured and broken\" from \"could\n * not measure\" — the same discipline the reports themselves use. Nothing here writes anything, so\n * it is safe to run against production at any time.\n */\n\nimport type { CheckStatus, DiagnosticCheck, DiagnosticReport } from '../reportData'\nimport type { SiteAnalyticsConfig } from '../core/siteConfig'\nimport { PREEXISTING } from '../core/cutover'\nimport { shiftDays, formatInTimeZone } from '../core/ranges'\nimport { eventNameFilter, sumFirstMetric, type Ga4Client } from './ga4'\nimport type { VercelClient } from './vercel'\nimport { countOrders, type SanityQueryClient } from './orders'\n\n/** Inputs for a diagnostic run. */\nexport interface DiagnosticsInput {\n\tconfig: SiteAnalyticsConfig\n\tga4: Ga4Client | null\n\tvercel: VercelClient | null\n\tsanity: SanityQueryClient | null\n\t/** Injectable clock, so the checks are deterministic under test. */\n\tnow?: Date\n}\n\n/** Severity ordering, worst last. */\nconst SEVERITY: CheckStatus[] = ['pass', 'skipped', 'warn', 'fail']\n\n/** Reduce a set of checks to the worst status present. */\nfunction worst(checks: DiagnosticCheck[]): CheckStatus {\n\treturn checks.reduce<CheckStatus>((acc, check) => (SEVERITY.indexOf(check.status) > SEVERITY.indexOf(acc) ? check.status : acc), 'pass')\n}\n\n/**\n * Run every preflight check.\n *\n * Each check is independent and its failure is caught, so one broken upstream cannot hide the\n * results of the others — the point of this report is to see everything wrong at once.\n */\nexport async function runDiagnostics(input: DiagnosticsInput): Promise<DiagnosticReport> {\n\tconst { config, ga4, vercel, sanity } = input\n\tconst now = input.now ?? new Date()\n\tconst checks: DiagnosticCheck[] = []\n\n\tconst timezone = config.ga4?.timezone ?? 'UTC'\n\tconst today = formatInTimeZone(now, timezone)\n\n\t// --- GA4 reachability -----------------------------------------------------\n\tlet ga4Reachable = false\n\tlet reportedTimeZone: string | undefined\n\n\tif (!ga4) {\n\t\tchecks.push({\n\t\t\tid: 'ga4-auth',\n\t\t\tlabel: 'GA4 reachable',\n\t\t\tstatus: 'skipped',\n\t\t\tdetail: 'GA4 is not configured for this site.',\n\t\t})\n\t} else {\n\t\ttry {\n\t\t\tconst probe = await ga4.runReport({\n\t\t\t\tmetrics: [{ name: 'sessions' }],\n\t\t\t\tdateRanges: [{ startDate: shiftDays(today, -7), endDate: today }],\n\t\t\t})\n\t\t\tga4Reachable = true\n\t\t\treportedTimeZone = probe.timeZone\n\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-auth',\n\t\t\t\tlabel: 'GA4 reachable',\n\t\t\t\tstatus: 'pass',\n\t\t\t\tdetail: `Property ${config.ga4?.propertyId} answered. ${sumFirstMetric(probe)} sessions in the last 7 days.`,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-auth',\n\t\t\t\tlabel: 'GA4 reachable',\n\t\t\t\tstatus: 'fail',\n\t\t\t\tdetail: (e as Error).message,\n\t\t\t\tremedy:\n\t\t\t\t\t'Check the service account is valid and has Viewer on this property, and that the property id is the numeric one from GA4 Admin rather than the G- measurement id.',\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- Timezone agreement ---------------------------------------------------\n\tif (!ga4Reachable) {\n\t\tchecks.push({ id: 'ga4-timezone', label: 'Timezone matches config', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else if (!reportedTimeZone) {\n\t\tchecks.push({\n\t\t\tid: 'ga4-timezone',\n\t\t\tlabel: 'Timezone matches config',\n\t\t\tstatus: 'warn',\n\t\t\tdetail: 'GA4 did not report a timezone, so the configured value could not be confirmed.',\n\t\t})\n\t} else if (reportedTimeZone !== timezone) {\n\t\tchecks.push({\n\t\t\tid: 'ga4-timezone',\n\t\t\tlabel: 'Timezone matches config',\n\t\t\tstatus: 'fail',\n\t\t\tdetail: `Config says ${timezone}; the property is set to ${reportedTimeZone}.`,\n\t\t\tremedy:\n\t\t\t\t'Set ga4.timezone to the property timezone. A mismatch shifts day and week boundaries, so GA4, Vercel and Sanity stop agreeing about which period an event belongs to.',\n\t\t})\n\t} else {\n\t\tchecks.push({\n\t\t\tid: 'ga4-timezone',\n\t\t\tlabel: 'Timezone matches config',\n\t\t\tstatus: 'pass',\n\t\t\tdetail: `Both are ${timezone}.`,\n\t\t})\n\t}\n\n\t// --- Retention window -----------------------------------------------------\n\t// Measured rather than read: the Admin setting is not exposed to the Data API, but its effect\n\t// is — probe a window well beyond the 2-month default and see whether anything comes back.\n\tif (!ga4Reachable || !ga4) {\n\t\tchecks.push({ id: 'ga4-retention', label: 'Retention covers a year', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else {\n\t\ttry {\n\t\t\tconst oldStart = shiftDays(today, -365)\n\t\t\tconst oldEnd = shiftDays(today, -120)\n\t\t\tconst old = await ga4.runReport({\n\t\t\t\tmetrics: [{ name: 'sessions' }],\n\t\t\t\tdateRanges: [{ startDate: oldStart, endDate: oldEnd }],\n\t\t\t})\n\n\t\t\tconst oldSessions = sumFirstMetric(old)\n\n\t\t\tif (oldSessions > 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-retention',\n\t\t\t\t\tlabel: 'Retention covers a year',\n\t\t\t\t\tstatus: 'pass',\n\t\t\t\t\tdetail: `${oldSessions} sessions found between ${oldStart} and ${oldEnd}, so the year range has data.`,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-retention',\n\t\t\t\t\tlabel: 'Retention covers a year',\n\t\t\t\t\tstatus: 'warn',\n\t\t\t\t\tdetail: `No sessions between ${oldStart} and ${oldEnd}. Either the site had no traffic then, or event-data retention is set shorter than that window.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\t'Check GA4 Admin, Data Settings, Data Retention and set it to 14 months. It does not backfill, so the sooner it is changed the sooner the year range becomes usable.',\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-retention',\n\t\t\t\tlabel: 'Retention covers a year',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not probe the retention window: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- Configured events actually fire --------------------------------------\n\tif (!ga4Reachable || !ga4) {\n\t\tchecks.push({ id: 'ga4-events', label: 'Configured events fire', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else {\n\t\ttry {\n\t\t\tconst seen = await ga4.runReport({\n\t\t\t\tdimensions: [{ name: 'eventName' }],\n\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\tdateRanges: [{ startDate: shiftDays(today, -30), endDate: today }],\n\t\t\t\tlimit: 200,\n\t\t\t})\n\n\t\t\tconst firing = new Set(seen.rows.map((row) => row.dimensions[0]).filter(Boolean) as string[])\n\n\t\t\t// Events the config claims are long-established but which GA4 has never seen.\n\t\t\tconst claimedButAbsent = Object.entries(config.eventCutovers)\n\t\t\t\t.filter(([name, cutover]) => cutover === PREEXISTING && !firing.has(name))\n\t\t\t\t.map(([name]) => name)\n\n\t\t\t// Events GA4 is recording that the config does not know about, so they are unreportable.\n\t\t\tconst firingButUnconfigured = [...firing].filter((name) => !(name in config.eventCutovers))\n\n\t\t\tif (claimedButAbsent.length > 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-events',\n\t\t\t\t\tlabel: 'Configured events fire',\n\t\t\t\t\tstatus: 'fail',\n\t\t\t\t\tdetail: `Config marks these as already live, but GA4 saw none in the last 30 days: ${claimedButAbsent.join(', ')}.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\t'Either the event is not actually firing, or the cutover map is wrong. Panels depending on it will show figures that look real but are not.',\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-events',\n\t\t\t\t\tlabel: 'Configured events fire',\n\t\t\t\t\tstatus: 'pass',\n\t\t\t\t\tdetail: `All events marked live are firing. ${firing.size} distinct events seen in the last 30 days.`,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (firingButUnconfigured.length > 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-unconfigured-events',\n\t\t\t\t\tlabel: 'No unreportable events',\n\t\t\t\t\tstatus: 'warn',\n\t\t\t\t\tdetail: `GA4 is recording events absent from the cutover map, so they cannot be reported: ${firingButUnconfigured.slice(0, 10).join(', ')}${firingButUnconfigured.length > 10 ? '…' : ''}.`,\n\t\t\t\t\tremedy: 'Add them to eventCutovers if they are worth reporting.',\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-events',\n\t\t\t\tlabel: 'Configured events fire',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not list events: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- purchase carries a join key ------------------------------------------\n\tif (!ga4Reachable || !ga4) {\n\t\tchecks.push({ id: 'ga4-transaction-id', label: 'Purchases carry transaction_id', status: 'skipped', detail: 'GA4 did not answer.' })\n\t} else {\n\t\ttry {\n\t\t\tconst purchases = await ga4.runReport({\n\t\t\t\tdimensions: [{ name: 'transactionId' }],\n\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\tdateRanges: [{ startDate: shiftDays(today, -90), endDate: today }],\n\t\t\t\tdimensionFilter: eventNameFilter('purchase'),\n\t\t\t\tlimit: 50,\n\t\t\t})\n\n\t\t\tconst ids = purchases.rows.map((row) => row.dimensions[0] ?? '')\n\t\t\tconst usable = ids.filter((id) => id && id !== '(not set)')\n\n\t\t\tif (ids.length === 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\t\tstatus: 'skipped',\n\t\t\t\t\tdetail: 'No purchase events in the last 90 days to inspect.',\n\t\t\t\t})\n\t\t\t} else if (usable.length === 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\t\tstatus: 'fail',\n\t\t\t\t\tdetail: `${ids.length} purchase rows, none with a usable transaction_id.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\t'Send transaction_id (the Sanity orderNumber) on every purchase event. Without it a GA4 purchase cannot be reconciled against an order, so the conversion anchor cannot be verified.',\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\t\tstatus: usable.length === ids.length ? 'pass' : 'warn',\n\t\t\t\t\tdetail: `${usable.length} of ${ids.length} purchase rows carry a transaction_id.`,\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'ga4-transaction-id',\n\t\t\t\tlabel: 'Purchases carry transaction_id',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not inspect purchases: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- GA4 purchases against Sanity orders ----------------------------------\n\tif (!ga4Reachable || !ga4 || !sanity) {\n\t\tchecks.push({\n\t\t\tid: 'purchase-order-agreement',\n\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\tstatus: 'skipped',\n\t\t\tdetail: 'Needs both GA4 and Sanity.',\n\t\t})\n\t} else {\n\t\ttry {\n\t\t\tconst start = shiftDays(today, -30)\n\t\t\tconst [ga4Purchases, orders] = await Promise.all([\n\t\t\t\tga4.runReport({\n\t\t\t\t\tmetrics: [{ name: 'eventCount' }],\n\t\t\t\t\tdateRanges: [{ startDate: start, endDate: today }],\n\t\t\t\t\tdimensionFilter: eventNameFilter('purchase'),\n\t\t\t\t}),\n\t\t\t\tcountOrders(sanity, config.orders.documentType, start, today, config.orders.excludeFilter),\n\t\t\t])\n\n\t\t\tconst tracked = sumFirstMetric(ga4Purchases)\n\t\t\tconst actual = orders.total\n\n\t\t\tif (actual === 0 && tracked === 0) {\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'purchase-order-agreement',\n\t\t\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\t\t\tstatus: 'skipped',\n\t\t\t\t\tdetail: 'No orders and no purchase events in the last 30 days.',\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tconst divergence = actual > 0 ? Math.abs(tracked - actual) / actual : 1\n\t\t\t\tchecks.push({\n\t\t\t\t\tid: 'purchase-order-agreement',\n\t\t\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\t\t\tstatus: divergence <= 0.15 ? 'pass' : 'warn',\n\t\t\t\t\tdetail: `GA4 recorded ${tracked} purchases; Sanity has ${actual} orders in the same 30 days.`,\n\t\t\t\t\tremedy:\n\t\t\t\t\t\tdivergence > 0.15\n\t\t\t\t\t\t\t? 'A large gap usually means purchase is firing on a page some buyers never reach, firing twice, or being blocked. Worth resolving before trusting any conversion figure.'\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\t})\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'purchase-order-agreement',\n\t\t\t\tlabel: 'GA4 purchases match orders',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: `Could not compare: ${(e as Error).message}`,\n\t\t\t})\n\t\t}\n\t}\n\n\t// --- Vercel ---------------------------------------------------------------\n\tif (!vercel) {\n\t\tchecks.push({\n\t\t\tid: 'vercel',\n\t\t\tlabel: 'Vercel Web Analytics reachable',\n\t\t\tstatus: 'skipped',\n\t\t\tdetail: 'Vercel is not configured for this site. Measurement health will have only one pageview source.',\n\t\t})\n\t} else {\n\t\ttry {\n\t\t\tconst result = await vercel.pageviews(shiftDays(today, -7), today)\n\t\t\tchecks.push({\n\t\t\t\tid: 'vercel',\n\t\t\t\tlabel: 'Vercel Web Analytics reachable',\n\t\t\t\tstatus: 'pass',\n\t\t\t\tdetail: `${result.total} pageviews in the last 7 days.`,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tchecks.push({\n\t\t\t\tid: 'vercel',\n\t\t\t\tlabel: 'Vercel Web Analytics reachable',\n\t\t\t\tstatus: 'warn',\n\t\t\t\tdetail: (e as Error).message,\n\t\t\t\tremedy:\n\t\t\t\t\t'Web Analytics API access depends on plan tier. Without it, measurement health cannot compare two pageview sources and the panel loses its point.',\n\t\t\t})\n\t\t}\n\t}\n\n\treturn { checks, verdict: worst(checks) }\n}\n\nexport type { CheckStatus, DiagnosticCheck, DiagnosticReport } from '../reportData'\n","/**\n * The mountable Next.js API-route handler.\n *\n * Ships from this package rather than being copy-pasted into each site so the three foundries\n * cannot drift apart: a fix applied here reaches all of them on a version bump, instead of being\n * applied to one repo and forgotten in the other two. A consuming site's route is then:\n *\n * // pages/api/visitor-insights/[report].js\n * import { createVisitorInsightsHandler } from '@liiift-studio/sanity-visitor-insights/server'\n * export default createVisitorInsightsHandler({ config: mySiteConfig })\n *\n * Credentials are read from the environment inside this module and never leave the server.\n */\n\nimport {\n\tisReportName,\n\ttype DateRange,\n\ttype RangeKey,\n\ttype ReportEnvelope,\n\ttype SourceName,\n\ttype SourceStatus,\n} from '../types'\nimport { assertValidSiteConfig, type SiteAnalyticsConfig } from '../core/siteConfig'\nimport { coverageNotices } from '../core/cutover'\nimport { resolveRange, provisionalNotice } from '../core/ranges'\nimport { applyCors, requireStudioUser, type HandlerRequest, type HandlerResponse } from './auth'\nimport { createGa4Client, type Ga4Client } from './ga4'\nimport { createVercelClient, type VercelClient } from './vercel'\nimport { parseServiceAccountKey } from './googleAuth'\nimport { cacheKey, withCache, DEFAULT_TTL_MS } from './cache'\nimport type { SanityQueryClient } from './orders'\nimport { measurementHealth } from './reports/measurementHealth'\nimport { acquisition } from './reports/acquisition'\nimport { journey } from './reports/journey'\nimport { typefaceInterest } from './reports/typefaceInterest'\nimport { runDiagnostics } from './diagnostics'\nimport { JOURNEY_STEPS } from './reports/journey'\n\n/** Environment variables this handler reads. Names are fixed so all three sites match. */\nexport const ENV_VARS = {\n\t/** Service-account JSON (raw or base64) with Viewer on the GA4 property. */\n\tgoogleServiceAccount: 'VISITOR_INSIGHTS_GA4_SERVICE_ACCOUNT',\n\t/** Vercel API token with read access to the project. */\n\tvercelToken: 'VISITOR_INSIGHTS_VERCEL_TOKEN',\n} as const\n\n/** Options for building a handler. */\nexport interface HandlerOptions {\n\t/** This site's description of itself. */\n\tconfig: SiteAnalyticsConfig\n\t/**\n\t * Sanity client used for order counts. Supply the site's existing server-side client so this\n\t * package does not need its own Sanity credentials.\n\t */\n\tsanityClient?: SanityQueryClient\n\t/** Sanity project id used to verify Studio tokens. Defaults to `SANITY_STUDIO_PROJECT_ID`. */\n\tsanityProjectId?: string\n\t/** Cache lifetime for report responses. */\n\tcacheTtlMs?: number\n}\n\n/** Valid range keys, as an allow-list for the query parameter. */\nconst RANGE_KEYS: RangeKey[] = ['week', 'quarter', 'year']\n\n/** Read a query parameter that may arrive as a string or an array. */\nfunction param(req: HandlerRequest, name: string): string | undefined {\n\tconst raw = req.query?.[name]\n\treturn Array.isArray(raw) ? raw[0] : raw\n}\n\n/**\n * Build the API-route handler for a site.\n *\n * @param options - the site config and its Sanity client\n * @returns a Next.js Pages Router API handler\n */\nexport function createVisitorInsightsHandler(options: HandlerOptions) {\n\t// Fail at construction rather than per-request, so a misconfiguration surfaces on deploy.\n\tassertValidSiteConfig(options.config)\n\n\tconst config = options.config\n\tconst ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS\n\n\treturn async function handler(req: HandlerRequest, res: HandlerResponse): Promise<void> {\n\t\tif (applyCors(req, res, config.allowedStudioOrigins ?? [])) return\n\n\t\tif (req.method !== 'GET') {\n\t\t\tres.status(405).json({ error: 'Method not allowed' })\n\t\t\treturn\n\t\t}\n\n\t\tconst sanityProjectId = options.sanityProjectId ?? process.env.SANITY_STUDIO_PROJECT_ID ?? ''\n\t\tconst user = await requireStudioUser(req, res, sanityProjectId)\n\t\tif (!user) return\n\n\t\t// Strict allow-list. The report name selects from a fixed set and is never used to build\n\t\t// an upstream request path or to look up code dynamically.\n\t\tconst reportName = param(req, 'report')\n\t\tif (!isReportName(reportName)) {\n\t\t\tres.status(400).json({ error: 'Unknown report' })\n\t\t\treturn\n\t\t}\n\n\t\tconst rangeKey = param(req, 'range') ?? 'week'\n\t\tif (!RANGE_KEYS.includes(rangeKey as RangeKey)) {\n\t\t\tres.status(400).json({ error: 'Unknown range' })\n\t\t\treturn\n\t\t}\n\n\t\t// Ranges are anchored to the GA4 property's timezone so all three sources agree on\n\t\t// where a day begins. Falls back to UTC only when GA4 is not configured at all.\n\t\tconst timezone = config.ga4?.timezone ?? 'UTC'\n\t\tconst range = resolveRange(rangeKey as RangeKey, timezone)\n\n\t\tconst sources: Partial<Record<SourceName, SourceStatus>> = {}\n\n\t\tconst serviceAccount = parseServiceAccountKey(process.env[ENV_VARS.googleServiceAccount])\n\t\tlet ga4: Ga4Client | null = null\n\t\tif (!config.ga4) {\n\t\t\tsources.ga4 = { status: 'unconfigured' }\n\t\t} else if (!serviceAccount) {\n\t\t\tsources.ga4 = { status: 'error', message: 'Service account missing or unparseable' }\n\t\t} else {\n\t\t\tga4 = createGa4Client(config.ga4.propertyId, serviceAccount)\n\t\t\tsources.ga4 = { status: 'ok' }\n\t\t}\n\n\t\tconst vercelToken = process.env[ENV_VARS.vercelToken]\n\t\tlet vercel: VercelClient | null = null\n\t\tif (!config.vercel) {\n\t\t\tsources.vercel = { status: 'unconfigured' }\n\t\t} else if (!vercelToken) {\n\t\t\tsources.vercel = { status: 'error', message: 'Vercel token missing' }\n\t\t} else {\n\t\t\tvercel = createVercelClient(config.vercel.projectId, vercelToken, config.vercel.teamId)\n\t\t\tsources.vercel = { status: 'ok' }\n\t\t}\n\n\t\tconst sanity = options.sanityClient ?? null\n\t\tsources.sanity = sanity ? { status: 'ok' } : { status: 'unconfigured' }\n\n\t\ttry {\n\t\t\tconst key = cacheKey(['vi', config.siteId, reportName, range.key, range.start, range.end])\n\n\t\t\tconst envelope = await withCache<ReportEnvelope<unknown>>(key, ttl, async () => {\n\t\t\t\tconst notices: string[] = []\n\n\t\t\t\tconst provisional = provisionalNotice(range)\n\t\t\t\tif (provisional) notices.push(provisional)\n\n\t\t\t\tconst data = await runReport(reportName, { config, range, ga4, vercel, sanity, notices })\n\n\t\t\t\treturn { report: reportName, range, sources, notices, data }\n\t\t\t})\n\n\t\t\tres.status(200).json(envelope)\n\t\t} catch (e) {\n\t\t\t// Upstream messages can echo request detail, so only a generic message crosses the wire.\n\t\t\tconsole.error(`Visitor insights: report \"${reportName}\" failed:`, (e as Error).message)\n\t\t\tres.status(502).json({ error: 'Report failed', report: reportName })\n\t\t}\n\t}\n}\n\n/** Arguments shared by every report. */\ninterface RunContext {\n\tconfig: SiteAnalyticsConfig\n\trange: DateRange\n\tga4: Ga4Client | null\n\tvercel: VercelClient | null\n\tsanity: SanityQueryClient | null\n\tnotices: string[]\n}\n\n/** Dispatch to a report by name. A plain switch, so the set of reachable code paths is closed. */\nasync function runReport(report: string, ctx: RunContext): Promise<unknown> {\n\tconst { config, range, ga4, vercel, sanity, notices } = ctx\n\n\tswitch (report) {\n\t\tcase 'measurement-health':\n\t\t\treturn measurementHealth({ config, range, ga4, vercel, sanity })\n\n\t\tcase 'acquisition':\n\t\t\tif (!ga4) throw new Error('GA4 is required for the acquisition report')\n\t\t\treturn acquisition(ga4, range)\n\n\t\tcase 'journey': {\n\t\t\tif (!ga4) throw new Error('GA4 is required for the journey report')\n\t\t\tnotices.push(...coverageNotices(config.eventCutovers, JOURNEY_STEPS.map((s) => s.event), range))\n\t\t\treturn journey(config, ga4, range)\n\t\t}\n\n\t\tcase 'diagnostics':\n\t\t\t// Deliberately tolerates missing sources: its whole job is to report what is absent.\n\t\t\treturn runDiagnostics({ config, ga4, vercel, sanity })\n\n\t\tcase 'typeface-interest':\n\t\t\tif (!ga4) throw new Error('GA4 is required for the typeface-interest report')\n\t\t\treturn typefaceInterest({ config, range, ga4, sanity })\n\n\t\tdefault:\n\t\t\tthrow new Error(`Unhandled report: ${report}`)\n\t}\n}\n"],"mappings":"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","names":["header"]}
|
package/dist/server.mjs
CHANGED
|
@@ -1137,12 +1137,15 @@ export {
|
|
|
1137
1137
|
clearCache,
|
|
1138
1138
|
coverageForRange,
|
|
1139
1139
|
coverageNotices,
|
|
1140
|
+
createGa4Client,
|
|
1141
|
+
createVercelClient,
|
|
1140
1142
|
createVisitorInsightsHandler,
|
|
1141
1143
|
daysBetween,
|
|
1142
1144
|
formatInTimeZone,
|
|
1143
1145
|
isReportName,
|
|
1144
1146
|
isValidTimeZone,
|
|
1145
1147
|
ok,
|
|
1148
|
+
parseServiceAccountKey,
|
|
1146
1149
|
partial,
|
|
1147
1150
|
previousRange,
|
|
1148
1151
|
provisionalDates,
|
package/dist/testing.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as Ga4Client,
|
|
1
|
+
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-BpDH6Vva.mjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as Ga4Client,
|
|
1
|
+
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-BpDH6Vva.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
|
@@ -3,6 +3,30 @@ interface SanityQueryClient {
|
|
|
3
3
|
fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Service-account access tokens for the GA4 Data API.
|
|
8
|
+
*
|
|
9
|
+
* Implemented directly against Google's OAuth2 token endpoint rather than through `googleapis`,
|
|
10
|
+
* which is a very large dependency to pull into a package that only needs one grant type. Signing
|
|
11
|
+
* a JWT with node:crypto keeps the dependency surface at zero and removes any risk of a
|
|
12
|
+
* credential-bearing SDK being reachable from the Studio entry point.
|
|
13
|
+
*/
|
|
14
|
+
/** The parts of a service-account JSON key this module uses. */
|
|
15
|
+
interface ServiceAccountKey {
|
|
16
|
+
client_email: string;
|
|
17
|
+
private_key: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a service-account key from an environment variable.
|
|
21
|
+
*
|
|
22
|
+
* Accepts either raw JSON or base64-encoded JSON, because multi-line JSON with embedded newlines
|
|
23
|
+
* is awkward to set in some dashboards and gets mangled often enough to be worth tolerating both.
|
|
24
|
+
*
|
|
25
|
+
* @param raw - the environment variable's value
|
|
26
|
+
* @returns the parsed key, or null when unset or unparseable
|
|
27
|
+
*/
|
|
28
|
+
declare function parseServiceAccountKey(raw: string | undefined): ServiceAccountKey | null;
|
|
29
|
+
|
|
6
30
|
/**
|
|
7
31
|
* GA4 Data API client.
|
|
8
32
|
*
|
|
@@ -54,6 +78,13 @@ interface Ga4Client {
|
|
|
54
78
|
runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
|
|
55
79
|
batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
|
|
56
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Build a GA4 client for one property.
|
|
83
|
+
*
|
|
84
|
+
* @param propertyId - numeric GA4 property id
|
|
85
|
+
* @param key - service-account key with Viewer on that property
|
|
86
|
+
*/
|
|
87
|
+
declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
|
|
57
88
|
|
|
58
89
|
/**
|
|
59
90
|
* Vercel Web Analytics client.
|
|
@@ -76,5 +107,13 @@ interface VercelPageviews {
|
|
|
76
107
|
interface VercelClient {
|
|
77
108
|
pageviews(start: string, end: string): Promise<VercelPageviews>;
|
|
78
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Build a Vercel Web Analytics client.
|
|
112
|
+
*
|
|
113
|
+
* @param projectId - the Vercel project id
|
|
114
|
+
* @param token - a Vercel API token with read access to that project
|
|
115
|
+
* @param teamId - team scope, required when the project belongs to a team
|
|
116
|
+
*/
|
|
117
|
+
declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
|
|
79
118
|
|
|
80
|
-
export type
|
|
119
|
+
export { type Ga4Client as G, type SanityQueryClient as S, type VercelClient as V, type Ga4Report as a, type Ga4ReportRequest as b, type ServiceAccountKey as c, type VercelPageviews as d, createGa4Client as e, createVercelClient as f, parseServiceAccountKey as p };
|
|
@@ -3,6 +3,30 @@ interface SanityQueryClient {
|
|
|
3
3
|
fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Service-account access tokens for the GA4 Data API.
|
|
8
|
+
*
|
|
9
|
+
* Implemented directly against Google's OAuth2 token endpoint rather than through `googleapis`,
|
|
10
|
+
* which is a very large dependency to pull into a package that only needs one grant type. Signing
|
|
11
|
+
* a JWT with node:crypto keeps the dependency surface at zero and removes any risk of a
|
|
12
|
+
* credential-bearing SDK being reachable from the Studio entry point.
|
|
13
|
+
*/
|
|
14
|
+
/** The parts of a service-account JSON key this module uses. */
|
|
15
|
+
interface ServiceAccountKey {
|
|
16
|
+
client_email: string;
|
|
17
|
+
private_key: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a service-account key from an environment variable.
|
|
21
|
+
*
|
|
22
|
+
* Accepts either raw JSON or base64-encoded JSON, because multi-line JSON with embedded newlines
|
|
23
|
+
* is awkward to set in some dashboards and gets mangled often enough to be worth tolerating both.
|
|
24
|
+
*
|
|
25
|
+
* @param raw - the environment variable's value
|
|
26
|
+
* @returns the parsed key, or null when unset or unparseable
|
|
27
|
+
*/
|
|
28
|
+
declare function parseServiceAccountKey(raw: string | undefined): ServiceAccountKey | null;
|
|
29
|
+
|
|
6
30
|
/**
|
|
7
31
|
* GA4 Data API client.
|
|
8
32
|
*
|
|
@@ -54,6 +78,13 @@ interface Ga4Client {
|
|
|
54
78
|
runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
|
|
55
79
|
batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
|
|
56
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Build a GA4 client for one property.
|
|
83
|
+
*
|
|
84
|
+
* @param propertyId - numeric GA4 property id
|
|
85
|
+
* @param key - service-account key with Viewer on that property
|
|
86
|
+
*/
|
|
87
|
+
declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
|
|
57
88
|
|
|
58
89
|
/**
|
|
59
90
|
* Vercel Web Analytics client.
|
|
@@ -76,5 +107,13 @@ interface VercelPageviews {
|
|
|
76
107
|
interface VercelClient {
|
|
77
108
|
pageviews(start: string, end: string): Promise<VercelPageviews>;
|
|
78
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Build a Vercel Web Analytics client.
|
|
112
|
+
*
|
|
113
|
+
* @param projectId - the Vercel project id
|
|
114
|
+
* @param token - a Vercel API token with read access to that project
|
|
115
|
+
* @param teamId - team scope, required when the project belongs to a team
|
|
116
|
+
*/
|
|
117
|
+
declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
|
|
79
118
|
|
|
80
|
-
export type
|
|
119
|
+
export { type Ga4Client as G, type SanityQueryClient as S, type VercelClient as V, type Ga4Report as a, type Ga4ReportRequest as b, type ServiceAccountKey as c, type VercelPageviews as d, createGa4Client as e, createVercelClient as f, parseServiceAccountKey as p };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/sanity-visitor-insights",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios — GA4, Vercel and order data reconciled honestly",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
package/src/server.ts
CHANGED
|
@@ -20,6 +20,12 @@ export type { TypefaceInterestData, TypefaceInterestRow } from './server/reports
|
|
|
20
20
|
export { JOURNEY_STEPS } from './server/reports/journey'
|
|
21
21
|
export { DESIGN_INDUSTRY_SOURCES } from './server/reports/acquisition'
|
|
22
22
|
export { runDiagnostics, type DiagnosticsInput } from './server/diagnostics'
|
|
23
|
+
|
|
24
|
+
// Client constructors. Needed to call runDiagnostics headlessly, which the README documents —
|
|
25
|
+
// without these exported that example could not actually be written.
|
|
26
|
+
export { createGa4Client, type Ga4Client, type Ga4Report, type Ga4ReportRequest } from './server/ga4'
|
|
27
|
+
export { createVercelClient, type VercelClient, type VercelPageviews } from './server/vercel'
|
|
28
|
+
export { parseServiceAccountKey, type ServiceAccountKey } from './server/googleAuth'
|
|
23
29
|
export type { CheckStatus, DiagnosticCheck, DiagnosticReport } from './reportData'
|
|
24
30
|
|
|
25
31
|
// Shared contract, re-exported so the server entry is self-sufficient.
|