@liiift-studio/sanity-visitor-insights 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/dist/index.d.mts +155 -0
  4. package/dist/index.d.ts +155 -0
  5. package/dist/index.js +652 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +609 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/ranges-D6AZwpmm.d.mts +282 -0
  10. package/dist/ranges-D6AZwpmm.d.ts +282 -0
  11. package/dist/server.d.mts +280 -0
  12. package/dist/server.d.ts +280 -0
  13. package/dist/server.js +937 -0
  14. package/dist/server.js.map +1 -0
  15. package/dist/server.mjs +882 -0
  16. package/dist/server.mjs.map +1 -0
  17. package/package.json +68 -0
  18. package/src/boundary.test.ts +93 -0
  19. package/src/core/core.test.ts +175 -0
  20. package/src/core/cutover.ts +109 -0
  21. package/src/core/ranges.ts +103 -0
  22. package/src/core/siteConfig.ts +150 -0
  23. package/src/index.ts +86 -0
  24. package/src/reportData.ts +120 -0
  25. package/src/server/auth.ts +133 -0
  26. package/src/server/cache.ts +75 -0
  27. package/src/server/createHandler.ts +199 -0
  28. package/src/server/ga4.ts +149 -0
  29. package/src/server/googleAuth.ts +139 -0
  30. package/src/server/orders.ts +127 -0
  31. package/src/server/reports/acquisition.ts +85 -0
  32. package/src/server/reports/journey.ts +112 -0
  33. package/src/server/reports/measurementHealth.ts +170 -0
  34. package/src/server/reports/typefaceInterest.ts +140 -0
  35. package/src/server/vercel.ts +68 -0
  36. package/src/server.ts +27 -0
  37. package/src/studio/Figure.tsx +175 -0
  38. package/src/studio/VisitorInsightsTool.tsx +247 -0
  39. package/src/studio/panels.tsx +233 -0
  40. package/src/studio/useReport.ts +99 -0
  41. package/src/types.ts +135 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Order counts from Sanity, used only as a conversion anchor.
3
+ *
4
+ * Orders on these sites carry customer names, emails and postal addresses. Joining that to
5
+ * behavioural analytics would turn aggregate statistics into personal-data processing and put the
6
+ * GA4 property at risk under Google's terms, so no query in this file may project a PII field.
7
+ * The projections below are allow-lists, not conveniences: every GROQ query here names the exact
8
+ * fields it returns, and none of them is a customer identifier.
9
+ *
10
+ * Revenue is likewise absent. This package reports behaviour; the sales portal reports sales.
11
+ */
12
+
13
+ /** Fields safe to read from an order. Nothing here identifies a person. */
14
+ const SAFE_ORDER_PROJECTION = '{ _createdAt, orderStatus }'
15
+
16
+ /** A daily count of orders. */
17
+ export interface OrderCounts {
18
+ byDate: Record<string, number>
19
+ total: number
20
+ }
21
+
22
+ /** Orders attributable to a typeface, for the interest report. */
23
+ export interface TypefaceOrderCounts {
24
+ /** Typeface title to order count. */
25
+ byTypeface: Record<string, number>
26
+ total: number
27
+ }
28
+
29
+ /** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
30
+ export interface SanityQueryClient {
31
+ fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>
32
+ }
33
+
34
+ /**
35
+ * Build a GROQ filter for orders in a date range.
36
+ * `excludeFilter` lets a site drop non-typeface orders, e.g. merch-only orders on Darden, which
37
+ * would otherwise inflate a family's apparent purchase count.
38
+ */
39
+ function orderFilter(documentType: string, excludeFilter?: string): string {
40
+ const clauses = [
41
+ `_type == $documentType`,
42
+ `_createdAt >= $start`,
43
+ `_createdAt <= $end`,
44
+ ]
45
+ if (excludeFilter) clauses.push(`(${excludeFilter})`)
46
+ return clauses.join(' && ')
47
+ }
48
+
49
+ /**
50
+ * Count orders per day across a range.
51
+ *
52
+ * Bounds are passed as full timestamps because `_createdAt` is a UTC datetime; comparing it to a
53
+ * bare date would silently drop the last day's orders.
54
+ *
55
+ * @param client - a Sanity client
56
+ * @param documentType - the site's order document type
57
+ * @param start - inclusive ISO date
58
+ * @param end - inclusive ISO date
59
+ * @param excludeFilter - optional GROQ clause excluding non-typeface orders
60
+ */
61
+ export async function countOrders(
62
+ client: SanityQueryClient,
63
+ documentType: string,
64
+ start: string,
65
+ end: string,
66
+ excludeFilter?: string,
67
+ ): Promise<OrderCounts> {
68
+ const query = `*[${orderFilter(documentType, excludeFilter)}]${SAFE_ORDER_PROJECTION}`
69
+
70
+ const orders = await client.fetch<Array<{ _createdAt: string }>>(query, {
71
+ documentType,
72
+ start: `${start}T00:00:00Z`,
73
+ end: `${end}T23:59:59Z`,
74
+ })
75
+
76
+ const byDate: Record<string, number> = {}
77
+ for (const order of orders) {
78
+ const date = order._createdAt.slice(0, 10)
79
+ byDate[date] = (byDate[date] ?? 0) + 1
80
+ }
81
+
82
+ return { byDate, total: orders.length }
83
+ }
84
+
85
+ /**
86
+ * Count orders per typeface across a range.
87
+ *
88
+ * Returns an empty result when the site has no usable typeface field on orders, rather than
89
+ * guessing — an absent join is reported as unavailable upstream, not as zero purchases.
90
+ *
91
+ * @param typefacesField - the order field holding typeface references, or null when absent
92
+ */
93
+ export async function countOrdersByTypeface(
94
+ client: SanityQueryClient,
95
+ documentType: string,
96
+ typefacesField: string | null,
97
+ start: string,
98
+ end: string,
99
+ excludeFilter?: string,
100
+ ): Promise<TypefaceOrderCounts | null> {
101
+ if (!typefacesField) return null
102
+
103
+ // Dereferences only the typeface title — never the order's customer fields.
104
+ const query = `*[${orderFilter(documentType, excludeFilter)}]{
105
+ "typefaces": ${typefacesField}[]->{ title }
106
+ }`
107
+
108
+ const orders = await client.fetch<Array<{ typefaces?: Array<{ title?: string } | null> | null }>>(query, {
109
+ documentType,
110
+ start: `${start}T00:00:00Z`,
111
+ end: `${end}T23:59:59Z`,
112
+ })
113
+
114
+ const byTypeface: Record<string, number> = {}
115
+ let total = 0
116
+
117
+ for (const order of orders) {
118
+ for (const typeface of order.typefaces ?? []) {
119
+ const title = typeface?.title
120
+ if (!title) continue
121
+ byTypeface[title] = (byTypeface[title] ?? 0) + 1
122
+ total += 1
123
+ }
124
+ }
125
+
126
+ return { byTypeface, total }
127
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Acquisition — where visitors come from.
3
+ *
4
+ * Design-industry referrers are pulled out as a named segment rather than left in a generic
5
+ * referrer table. A visit from Fonts In Use or Typewolf is a pre-qualified, industry-literate
6
+ * visitor and behaves nothing like average organic traffic; burying those rows among search and
7
+ * social discards the distinction a foundry actually acts on.
8
+ *
9
+ * `(not set)` rows are surfaced rather than swept into "other". On a type-foundry site they can be
10
+ * a large share — bookmarked visits, stripped referrers, AI crawlers — and hiding them makes the
11
+ * table look more complete than it is.
12
+ */
13
+
14
+ import type { AcquisitionData, SourceRow } from '../../reportData'
15
+ import type { DateRange } from '../../types'
16
+ import { sumFirstMetric, type Ga4Client } from '../ga4'
17
+
18
+ /** Referrer hosts that identify a design-industry source worth tracking separately. */
19
+ export const DESIGN_INDUSTRY_SOURCES = [
20
+ 'fontsinuse.com',
21
+ 'typewolf.com',
22
+ 'typographica.org',
23
+ 'fonts.google.com',
24
+ 'behance.net',
25
+ 'dribbble.com',
26
+ 'itsnicethat.com',
27
+ ] as const
28
+
29
+
30
+
31
+ /** Whether a GA4 source value is one of the unattributed buckets rather than a real referrer. */
32
+ function isUnattributed(source: string): boolean {
33
+ const normalised = source.toLowerCase()
34
+ return normalised === '(not set)' || normalised === '(direct)' || normalised === '(none)' || normalised === ''
35
+ }
36
+
37
+ /** Whether a GA4 source value matches a design-industry referrer. */
38
+ function isDesignIndustry(source: string): boolean {
39
+ const normalised = source.toLowerCase()
40
+ return DESIGN_INDUSTRY_SOURCES.some((known) => normalised === known || normalised.endsWith(`.${known}`))
41
+ }
42
+
43
+ /**
44
+ * Run the acquisition report.
45
+ *
46
+ * @param ga4 - client for the site's property
47
+ * @param range - the requested range
48
+ * @param limit - maximum source rows to return
49
+ */
50
+ export async function acquisition(ga4: Ga4Client, range: DateRange, limit = 25): Promise<AcquisitionData> {
51
+ const report = await ga4.runReport({
52
+ dimensions: [{ name: 'sessionSource' }, { name: 'sessionDefaultChannelGroup' }],
53
+ metrics: [{ name: 'sessions' }],
54
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
55
+ orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
56
+ limit,
57
+ })
58
+
59
+ const rows: SourceRow[] = report.rows.map((row) => {
60
+ const source = row.dimensions[0] ?? '(not set)'
61
+ const sessions = row.metrics[0]
62
+
63
+ return {
64
+ source,
65
+ channel: row.dimensions[1] ?? 'Unassigned',
66
+ sessions: Number.isFinite(sessions) ? (sessions as number) : 0,
67
+ designIndustry: isDesignIndustry(source),
68
+ unattributed: isUnattributed(source),
69
+ }
70
+ })
71
+
72
+ const totalSessions = sumFirstMetric(report)
73
+ const sumWhere = (predicate: (row: SourceRow) => boolean) =>
74
+ rows.filter(predicate).reduce((total, row) => total + row.sessions, 0)
75
+
76
+ return {
77
+ rows,
78
+ totalSessions,
79
+ designIndustryShare: totalSessions > 0 ? sumWhere((r) => r.designIndustry) / totalSessions : null,
80
+ unattributedShare: totalSessions > 0 ? sumWhere((r) => r.unattributed) / totalSessions : null,
81
+ rowsWithheld: report.thresholded,
82
+ }
83
+ }
84
+
85
+ export type { AcquisitionData, SourceRow } from '../../reportData'
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Journey — how far visitors get, and where they stop.
3
+ *
4
+ * This is an ordered step funnel, not a path graph, and that is a deliberate limit rather than a
5
+ * simplification. GA4's Data API has no path-exploration endpoint; Path Exploration is a UI-only
6
+ * feature. What is available is per-event totals and `runFunnelReport`, an alpha surface that
7
+ * returns step-conversion marginals — not observed sequences. Rendering ribbons from marginals
8
+ * would assert that a given visitor went A to B to C when no such co-occurrence was ever measured.
9
+ *
10
+ * So each step is reported as its own honest total, adjacent drop-off is derived between steps, and
11
+ * the response is explicitly flagged as an approximation for the UI to display.
12
+ *
13
+ * A step whose event is not instrumented on this site reports as unavailable, never as zero. On a
14
+ * site missing `begin_checkout`, the cart-to-checkout drop-off is not "100% drop-off" — it is
15
+ * unmeasured, and the two must not look alike.
16
+ */
17
+
18
+ import type { JourneyData, JourneyStep, ExitPage } from '../../reportData'
19
+ import type { DateRange, MetricValue } from '../../types'
20
+ import type { SiteAnalyticsConfig } from '../../core/siteConfig'
21
+ import { applyCoverage, coverageForRange } from '../../core/cutover'
22
+ import { eventNameFilter, sumFirstMetric, type Ga4Client } from '../ga4'
23
+
24
+ /** The funnel, in order. Each entry names the GA4 event that evidences the step. */
25
+ export const JOURNEY_STEPS = [
26
+ { key: 'landed', label: 'Landed', event: 'page_view' },
27
+ { key: 'viewed_typeface', label: 'Viewed a typeface', event: 'view_item' },
28
+ { key: 'tested', label: 'Used the type tester', event: 'tester_engaged' },
29
+ { key: 'added_to_cart', label: 'Added to cart', event: 'add_to_cart' },
30
+ { key: 'began_checkout', label: 'Began checkout', event: 'begin_checkout' },
31
+ { key: 'purchased', label: 'Purchased', event: 'purchase' },
32
+ ] as const
33
+
34
+
35
+
36
+
37
+ const APPROXIMATION_NOTE =
38
+ 'These are independent per-step totals, not tracked journeys. GA4 cannot report the actual path a ' +
39
+ 'visitor took, so a visitor counted at one step is not necessarily the same visitor counted at the next.'
40
+
41
+ /**
42
+ * Run the journey report.
43
+ *
44
+ * All step queries go in one batched call rather than one request per step, which would otherwise
45
+ * make this the most quota-expensive panel in the tool.
46
+ */
47
+ export async function journey(config: SiteAnalyticsConfig, ga4: Ga4Client, range: DateRange): Promise<JourneyData> {
48
+ // Only query steps whose event could return data; skip the rest to save quota.
49
+ const coverages = JOURNEY_STEPS.map((step) => ({
50
+ step,
51
+ coverage: coverageForRange(config.eventCutovers, step.event, range),
52
+ }))
53
+
54
+ const queryable = coverages.filter((entry) => entry.coverage.status !== 'none')
55
+
56
+ const reports = await ga4.batchRunReports(
57
+ queryable.map((entry) => ({
58
+ metrics: [{ name: 'eventCount' }],
59
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
60
+ dimensionFilter: eventNameFilter(entry.step.event),
61
+ })),
62
+ )
63
+
64
+ const countsByEvent = new Map<string, number>()
65
+ queryable.forEach((entry, index) => {
66
+ const report = reports[index]
67
+ if (report) countsByEvent.set(entry.step.event, sumFirstMetric(report))
68
+ })
69
+
70
+ const steps: JourneyStep[] = []
71
+ let previousMeasurable: number | null = null
72
+
73
+ for (const { step, coverage } of coverages) {
74
+ const raw = countsByEvent.has(step.event) ? (countsByEvent.get(step.event) as number) : null
75
+ const count = applyCoverage(raw, coverage)
76
+
77
+ const current = count.status === 'unavailable' ? null : count.value
78
+ const conversionFromPrevious =
79
+ previousMeasurable !== null && current !== null && previousMeasurable > 0
80
+ ? current / previousMeasurable
81
+ : null
82
+
83
+ steps.push({ key: step.key, label: step.label, event: step.event, count, conversionFromPrevious })
84
+
85
+ // Only advance the baseline on a measurable step, so an unavailable rung does not
86
+ // silently make the next step's conversion look like a collapse.
87
+ if (current !== null) previousMeasurable = current
88
+ }
89
+
90
+ let topExitPages: ExitPage[] = []
91
+ try {
92
+ const exits = await ga4.runReport({
93
+ dimensions: [{ name: 'pagePath' }],
94
+ metrics: [{ name: 'exits' }],
95
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
96
+ orderBys: [{ metric: { metricName: 'exits' }, desc: true }],
97
+ limit: 10,
98
+ })
99
+
100
+ topExitPages = exits.rows.map((row) => ({
101
+ path: row.dimensions[0] ?? '(unknown)',
102
+ exits: Number.isFinite(row.metrics[0]) ? (row.metrics[0] as number) : 0,
103
+ }))
104
+ } catch (e) {
105
+ // Exit pages are supplementary; losing them should not fail the funnel.
106
+ console.error('Visitor insights: exit-page query failed:', (e as Error).message)
107
+ }
108
+
109
+ return { steps, topExitPages, approximate: true, approximationNote: APPROXIMATION_NOTE }
110
+ }
111
+
112
+ export type { JourneyData, JourneyStep, ExitPage } from '../../reportData'
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Measurement Health — "what are we missing?", answered honestly.
3
+ *
4
+ * This panel began life as a "coverage gap" that subtracted Vercel pageviews from GA4 sessions and
5
+ * attributed the difference to consent, ad-blockers and bots. That is not a valid subtraction:
6
+ * sessions and pageviews are different units, so the difference is dominated by the unit mismatch
7
+ * rather than by anything missing. Worse, GA4 exposes no signal that separates consent-denied from
8
+ * blocked from bot traffic, so the attribution would have been a guess rendered with the authority
9
+ * of a measurement.
10
+ *
11
+ * What this reports instead:
12
+ * - GA4 pageviews against Vercel pageviews, the same unit on both sides
13
+ * - GA4 sessions and orders alongside as context, never subtracted
14
+ * - one residual, labelled unexplained, with the causes we can actually measure broken out
15
+ *
16
+ * The only cause we can measure is consent: a `consent_granted` event makes the acceptance rate a
17
+ * real number rather than an inference. Until that event is instrumented, the residual stays whole
18
+ * and the panel says why.
19
+ */
20
+
21
+ import type { MeasurementHealthData } from '../../reportData'
22
+ import type { DateRange, MetricValue } from '../../types'
23
+ import { ok, unavailable } from '../../types'
24
+ import type { SiteAnalyticsConfig } from '../../core/siteConfig'
25
+ import { coverageForRange } from '../../core/cutover'
26
+ import { sumFirstMetric, type Ga4Client } from '../ga4'
27
+ import type { VercelClient } from '../vercel'
28
+ import { countOrders, type SanityQueryClient } from '../orders'
29
+
30
+ /** The event whose presence turns the consent share from a guess into a measurement. */
31
+ const CONSENT_EVENT = 'consent_granted'
32
+
33
+
34
+ /** Inputs for the measurement-health report. */
35
+ export interface MeasurementHealthInput {
36
+ config: SiteAnalyticsConfig
37
+ range: DateRange
38
+ ga4: Ga4Client | null
39
+ vercel: VercelClient | null
40
+ sanity: SanityQueryClient | null
41
+ }
42
+
43
+ /** Build the plain-language reading shown beneath the figures. */
44
+ function interpret(ga4Views: MetricValue, vercelViews: MetricValue, shortfall: number | null, consent: MetricValue): string {
45
+ if (ga4Views.status === 'unavailable' && vercelViews.status === 'unavailable') {
46
+ return 'Neither source answered, so nothing can be said about coverage for this range.'
47
+ }
48
+ if (shortfall === null) {
49
+ return 'Only one pageview source answered, so the two cannot be compared for this range.'
50
+ }
51
+
52
+ const percent = Math.round(Math.abs(shortfall) * 100)
53
+
54
+ if (Math.abs(shortfall) < 0.05) {
55
+ return `GA4 and Vercel agree to within ${percent}% on pageviews. Nothing here suggests a measurement problem.`
56
+ }
57
+
58
+ const direction = shortfall > 0 ? 'fewer' : 'more'
59
+ const base = `GA4 recorded ${percent}% ${direction} pageviews than Vercel.`
60
+
61
+ if (consent.status === 'unavailable') {
62
+ return `${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.`
63
+ }
64
+
65
+ const consentPercent = Math.round((1 - consent.value / 100) * 100)
66
+ return `${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.`
67
+ }
68
+
69
+ /**
70
+ * Run the measurement-health report.
71
+ *
72
+ * Each source is queried independently and a failure in one degrades only its own figures, so the
73
+ * panel can always say which source did not answer rather than showing an unexplained blank.
74
+ */
75
+ export async function measurementHealth(input: MeasurementHealthInput): Promise<MeasurementHealthData> {
76
+ const { config, range, ga4, vercel, sanity } = input
77
+
78
+ let ga4Pageviews: MetricValue = unavailable('source_error', 'GA4 not configured')
79
+ let ga4Sessions: MetricValue = unavailable('source_error', 'GA4 not configured')
80
+ let consentRate: MetricValue = unavailable('not_instrumented')
81
+
82
+ if (ga4) {
83
+ try {
84
+ // One batched call rather than three separate quota-charged requests.
85
+ const [views, sessions, consent] = await ga4.batchRunReports([
86
+ {
87
+ metrics: [{ name: 'screenPageViews' }],
88
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
89
+ },
90
+ {
91
+ metrics: [{ name: 'sessions' }],
92
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
93
+ },
94
+ {
95
+ metrics: [{ name: 'eventCount' }],
96
+ dimensions: [{ name: 'eventName' }],
97
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
98
+ dimensionFilter: {
99
+ filter: {
100
+ fieldName: 'eventName',
101
+ stringFilter: { matchType: 'EXACT', value: CONSENT_EVENT },
102
+ },
103
+ },
104
+ },
105
+ ])
106
+
107
+ if (views) ga4Pageviews = ok(sumFirstMetric(views))
108
+ if (sessions) ga4Sessions = ok(sumFirstMetric(sessions))
109
+
110
+ const consentCoverage = coverageForRange(config.eventCutovers, CONSENT_EVENT, range)
111
+ if (consentCoverage.status === 'full' && consent && ga4Sessions.status === 'ok' && ga4Sessions.value > 0) {
112
+ // Expressed as a percentage of sessions, capped since one session can fire it twice.
113
+ const rate = Math.min(100, (sumFirstMetric(consent) / ga4Sessions.value) * 100)
114
+ consentRate = ok(Math.round(rate * 10) / 10)
115
+ } else if (consentCoverage.status === 'partial') {
116
+ consentRate = unavailable('before_cutover', `Instrumented from ${consentCoverage.cutover}`)
117
+ }
118
+ } catch (e) {
119
+ console.error('Visitor insights: GA4 measurement-health query failed:', (e as Error).message)
120
+ ga4Pageviews = unavailable('source_error')
121
+ ga4Sessions = unavailable('source_error')
122
+ }
123
+ }
124
+
125
+ let vercelPageviews: MetricValue = unavailable('source_error', 'Vercel not configured')
126
+ if (vercel) {
127
+ try {
128
+ const result = await vercel.pageviews(range.start, range.end)
129
+ vercelPageviews = ok(result.total)
130
+ } catch (e) {
131
+ console.error('Visitor insights: Vercel query failed:', (e as Error).message)
132
+ vercelPageviews = unavailable('source_error')
133
+ }
134
+ }
135
+
136
+ let orders: MetricValue = unavailable('source_error', 'Sanity not configured')
137
+ if (sanity) {
138
+ try {
139
+ const counts = await countOrders(
140
+ sanity,
141
+ config.orders.documentType,
142
+ range.start,
143
+ range.end,
144
+ config.orders.excludeFilter,
145
+ )
146
+ orders = ok(counts.total)
147
+ } catch (e) {
148
+ console.error('Visitor insights: order count failed:', (e as Error).message)
149
+ orders = unavailable('source_error')
150
+ }
151
+ }
152
+
153
+ // Computed only when both operands are real numbers, and only between matching units.
154
+ const shortfallRatio =
155
+ ga4Pageviews.status !== 'unavailable' && vercelPageviews.status !== 'unavailable' && vercelPageviews.value > 0
156
+ ? (vercelPageviews.value - ga4Pageviews.value) / vercelPageviews.value
157
+ : null
158
+
159
+ return {
160
+ ga4Pageviews,
161
+ vercelPageviews,
162
+ shortfallRatio,
163
+ ga4Sessions,
164
+ orders,
165
+ consentRate,
166
+ interpretation: interpret(ga4Pageviews, vercelPageviews, shortfallRatio, consentRate),
167
+ }
168
+ }
169
+
170
+ export type { MeasurementHealthData } from '../../reportData'
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Typeface interest — viewed, tested, bought, per family.
3
+ *
4
+ * This is the one panel GA4's own UI cannot produce, because it joins GA4 engagement to Sanity
5
+ * order documents. It is also the panel most easily misread, so two things are stated in the
6
+ * response rather than left to the reader:
7
+ *
8
+ * 1. It measures aggregate interest per family, not one person's journey. In foundry sales the
9
+ * designer who tests a typeface is frequently not the person who later pays for it, and the
10
+ * gap between the two can be months. A low tested-to-bought ratio is therefore not a broken
11
+ * funnel, and the UI must not frame it as one.
12
+ * 2. "Tested" counts only genuine engagement — a `tester_engaged` event fired when the visitor
13
+ * changed something. Firing on tester open would count every default-state page load as a
14
+ * test and make the ratio meaningless.
15
+ */
16
+
17
+ import type { TypefaceInterestData, TypefaceInterestRow } from '../../reportData'
18
+ import type { DateRange, MetricValue } from '../../types'
19
+ import { ok, unavailable } from '../../types'
20
+ import type { SiteAnalyticsConfig } from '../../core/siteConfig'
21
+ import { coverageForRange } from '../../core/cutover'
22
+ import { eventNameFilter, type Ga4Client } from '../ga4'
23
+ import { countOrdersByTypeface, type SanityQueryClient } from '../orders'
24
+
25
+ /** GA4 event evidencing a typeface page view. */
26
+ const VIEW_EVENT = 'view_item'
27
+
28
+ /** GA4 event evidencing real type-tester engagement. */
29
+ const TEST_EVENT = 'tester_engaged'
30
+
31
+
32
+
33
+ const INTERPRETATION_NOTE =
34
+ 'Aggregate interest per family, not individual journeys. The person who tests a typeface is often ' +
35
+ 'not the person who buys it, and may buy months later — so a low tested-to-bought ratio is not ' +
36
+ 'necessarily a conversion problem.'
37
+
38
+ /**
39
+ * Read per-typeface event counts keyed by the GA4 `itemName` dimension.
40
+ * Returns null when the event is not usable for this range, so the caller can distinguish
41
+ * "no data" from "no interest".
42
+ */
43
+ async function countsByItem(
44
+ config: SiteAnalyticsConfig,
45
+ ga4: Ga4Client,
46
+ range: DateRange,
47
+ eventName: string,
48
+ ): Promise<Map<string, number> | null> {
49
+ if (coverageForRange(config.eventCutovers, eventName, range).status === 'none') return null
50
+
51
+ const report = await ga4.runReport({
52
+ dimensions: [{ name: 'itemName' }],
53
+ metrics: [{ name: 'eventCount' }],
54
+ dateRanges: [{ startDate: range.start, endDate: range.end }],
55
+ dimensionFilter: eventNameFilter(eventName),
56
+ orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }],
57
+ limit: 100,
58
+ })
59
+
60
+ const counts = new Map<string, number>()
61
+ for (const row of report.rows) {
62
+ const name = row.dimensions[0]
63
+ const value = row.metrics[0]
64
+ if (name && Number.isFinite(value)) counts.set(name, value as number)
65
+ }
66
+
67
+ return counts
68
+ }
69
+
70
+ /** Inputs for the typeface-interest report. */
71
+ export interface TypefaceInterestInput {
72
+ config: SiteAnalyticsConfig
73
+ range: DateRange
74
+ ga4: Ga4Client
75
+ sanity: SanityQueryClient | null
76
+ }
77
+
78
+ /** Run the typeface-interest report. */
79
+ export async function typefaceInterest(input: TypefaceInterestInput): Promise<TypefaceInterestData> {
80
+ const { config, range, ga4, sanity } = input
81
+
82
+ const [viewed, tested] = await Promise.all([
83
+ countsByItem(config, ga4, range, VIEW_EVENT),
84
+ countsByItem(config, ga4, range, TEST_EVENT),
85
+ ])
86
+
87
+ let bought: Map<string, number> | null = null
88
+ if (sanity) {
89
+ try {
90
+ const counts = await countOrdersByTypeface(
91
+ sanity,
92
+ config.orders.documentType,
93
+ config.orders.typefacesField,
94
+ range.start,
95
+ range.end,
96
+ config.orders.excludeFilter,
97
+ )
98
+ if (counts) bought = new Map(Object.entries(counts.byTypeface))
99
+ } catch (e) {
100
+ console.error('Visitor insights: per-typeface order count failed:', (e as Error).message)
101
+ }
102
+ }
103
+
104
+ // Union of every family seen by any source, so a family that sold but was never viewed
105
+ // (or vice versa) still appears rather than being silently dropped.
106
+ const families = new Set<string>([
107
+ ...(viewed?.keys() ?? []),
108
+ ...(tested?.keys() ?? []),
109
+ ...(bought?.keys() ?? []),
110
+ ])
111
+
112
+ const metric = (counts: Map<string, number> | null, family: string, missingReason: 'not_instrumented' | 'not_applicable'): MetricValue =>
113
+ counts === null ? unavailable(missingReason) : ok(counts.get(family) ?? 0)
114
+
115
+ const rows: TypefaceInterestRow[] = [...families].map((family) => {
116
+ const viewedMetric = metric(viewed, family, 'not_instrumented')
117
+ const testedMetric = metric(tested, family, 'not_instrumented')
118
+ const boughtMetric = bought === null ? unavailable('not_applicable', 'Orders do not resolve to typefaces on this site') : ok(bought.get(family) ?? 0)
119
+
120
+ const testRate =
121
+ viewedMetric.status !== 'unavailable' &&
122
+ testedMetric.status !== 'unavailable' &&
123
+ viewedMetric.value > 0
124
+ ? testedMetric.value / viewedMetric.value
125
+ : null
126
+
127
+ return { typeface: family, viewed: viewedMetric, tested: testedMetric, bought: boughtMetric, testRate }
128
+ })
129
+
130
+ // Most-viewed first, with unavailable views sorting last rather than as zero.
131
+ rows.sort((a, b) => {
132
+ const av = a.viewed.status === 'unavailable' ? -1 : a.viewed.value
133
+ const bv = b.viewed.status === 'unavailable' ? -1 : b.viewed.value
134
+ return bv - av
135
+ })
136
+
137
+ return { rows, interpretationNote: INTERPRETATION_NOTE, rowsWithheld: false }
138
+ }
139
+
140
+ export type { TypefaceInterestData, TypefaceInterestRow } from '../../reportData'