@liiift-studio/sanity-visitor-insights 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-BMEO-Jcw.mjs';
1
+ import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-B6d1Ng48.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, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-BMEO-Jcw.js';
1
+ import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-B6d1Ng48.js';
2
2
 
3
3
  /**
4
4
  * Test doubles and fixture builders for the report layer.
@@ -85,6 +85,10 @@ interface Ga4Client {
85
85
  * @param key - service-account key with Viewer on that property
86
86
  */
87
87
  declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
88
+ /** Build a dimension filter matching a single event name. */
89
+ declare function eventNameFilter(eventName: string): unknown;
90
+ /** Sum a report's first metric across all rows, ignoring suppressed cells. */
91
+ declare function sumFirstMetric(report: Ga4Report): number;
88
92
 
89
93
  /**
90
94
  * Vercel Web Analytics client.
@@ -120,4 +124,4 @@ interface VercelClient {
120
124
  */
121
125
  declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
122
126
 
123
- 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 };
127
+ 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, eventNameFilter as g, parseServiceAccountKey as p, sumFirstMetric as s };
@@ -85,6 +85,10 @@ interface Ga4Client {
85
85
  * @param key - service-account key with Viewer on that property
86
86
  */
87
87
  declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
88
+ /** Build a dimension filter matching a single event name. */
89
+ declare function eventNameFilter(eventName: string): unknown;
90
+ /** Sum a report's first metric across all rows, ignoring suppressed cells. */
91
+ declare function sumFirstMetric(report: Ga4Report): number;
88
92
 
89
93
  /**
90
94
  * Vercel Web Analytics client.
@@ -120,4 +124,4 @@ interface VercelClient {
120
124
  */
121
125
  declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
122
126
 
123
- 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 };
127
+ 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, eventNameFilter as g, parseServiceAccountKey as p, sumFirstMetric as s };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liiift-studio/sanity-visitor-insights",
3
- "version": "0.2.2",
4
- "description": "Visitor-behaviour analytics for type-foundry Sanity Studios — GA4, Vercel and order data reconciled honestly",
3
+ "version": "0.3.0",
4
+ "description": "Visitor-behaviour analytics for type-foundry Sanity Studios \u2014 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",
@@ -49,7 +49,9 @@ describe('coverageForRange', () => {
49
49
  it('reports partial coverage when the range straddles the cutover', () => {
50
50
  expect(coverageForRange(cutovers, 'begin_checkout', { start: '2026-08-01', end: '2026-10-01' })).toEqual({
51
51
  status: 'partial',
52
+ reason: 'spans_cutover',
52
53
  cutover: '2026-09-01',
54
+ outages: [],
53
55
  })
54
56
  })
55
57
 
@@ -69,7 +71,7 @@ describe('applyCoverage', () => {
69
71
  })
70
72
 
71
73
  it('marks a straddling range as partial rather than complete', () => {
72
- const metric = applyCoverage(120, { status: 'partial', cutover: '2026-09-01' })
74
+ const metric = applyCoverage(120, { status: 'partial', reason: 'spans_cutover', cutover: '2026-09-01', outages: [] })
73
75
  expect(metric.status).toBe('partial')
74
76
  expect(valueOrNull(metric)).toBe(120)
75
77
  })
@@ -97,6 +99,68 @@ describe('coverageNotices', () => {
97
99
  })
98
100
  })
99
101
 
102
+ describe('outages', () => {
103
+ // Darden's real case: ecommerce events kept firing in code but stopped reaching GA4 for
104
+ // nine months. Every one of those days returns an honest zero from the API.
105
+ const withOutage: Record<string, EventCutover> = {
106
+ purchase: {
107
+ from: PREEXISTING,
108
+ outages: [{ start: '2025-11-20', until: '2026-08-30', reason: 'gtag loader moved to lazyOnload' }],
109
+ },
110
+ still_broken: {
111
+ from: PREEXISTING,
112
+ outages: [{ start: '2026-01-01', until: null }],
113
+ },
114
+ }
115
+
116
+ it('reports a range sitting entirely inside an outage as unavailable, not zero', () => {
117
+ const coverage = coverageForRange(withOutage, 'purchase', { start: '2026-01-01', end: '2026-03-01' })
118
+ expect(coverage.status).toBe('none')
119
+ expect(coverage).toMatchObject({ reason: 'outage' })
120
+
121
+ // The crucial assertion: a real GA4 zero must not become a charted zero.
122
+ const metric = applyCoverage(0, coverage)
123
+ expect(metric.status).toBe('unavailable')
124
+ expect(valueOrNull(metric)).toBeNull()
125
+ })
126
+
127
+ it('reports a range straddling the end of an outage as partial and undercounted', () => {
128
+ const coverage = coverageForRange(withOutage, 'purchase', { start: '2026-08-01', end: '2026-09-30' })
129
+ expect(coverage).toMatchObject({ status: 'partial', reason: 'spans_outage' })
130
+
131
+ const metric = applyCoverage(12, coverage)
132
+ expect(metric.status).toBe('partial')
133
+ if (metric.status === 'partial') expect(metric.note).toContain('Undercounted')
134
+ })
135
+
136
+ it('treats a range fully after the outage as complete', () => {
137
+ expect(coverageForRange(withOutage, 'purchase', { start: '2026-09-01', end: '2026-09-30' })).toEqual({ status: 'full' })
138
+ })
139
+
140
+ it('treats a range fully before the outage as complete', () => {
141
+ expect(coverageForRange(withOutage, 'purchase', { start: '2025-06-01', end: '2025-11-01' })).toEqual({ status: 'full' })
142
+ })
143
+
144
+ it('treats an unresolved outage as ongoing', () => {
145
+ const coverage = coverageForRange(withOutage, 'still_broken', { start: '2026-06-01', end: '2026-08-30' })
146
+ expect(coverage).toMatchObject({ status: 'none', reason: 'outage' })
147
+ })
148
+
149
+ it('still accepts the plain shorthand forms', () => {
150
+ expect(coverageForRange({ a: PREEXISTING }, 'a', { start: '2026-01-01', end: '2026-02-01' })).toEqual({ status: 'full' })
151
+ expect(coverageForRange({ a: null }, 'a', { start: '2026-01-01', end: '2026-02-01' })).toEqual({
152
+ status: 'none', reason: 'not_instrumented',
153
+ })
154
+ })
155
+
156
+ it('explains an outage in the notices rather than leaving a silent dip', () => {
157
+ const notices = coverageNotices(withOutage, ['purchase'], { start: '2026-08-01', end: '2026-09-30' })
158
+ expect(notices).toHaveLength(1)
159
+ expect(notices[0]).toContain('not a real decline')
160
+ expect(notices[0]).toContain('lazyOnload')
161
+ })
162
+ })
163
+
100
164
  describe('ranges', () => {
101
165
  it('anchors the range end to the property timezone, not the host timezone', () => {
102
166
  // 02:00 UTC on the 26th is still the 25th in Los Angeles. A range anchored to UTC would
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Event instrumentation cutovers.
2
+ * Event instrumentation history — when an event started, and when it stopped.
3
3
  *
4
4
  * GA4 cannot backfill events: data from before an event was instrumented does not exist and never
5
5
  * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn
6
6
  * across it reads as a change in visitor behaviour when it is really a change in what was counted.
7
7
  *
8
+ * Outages are the same problem arriving from the other direction, and are easy to miss because the
9
+ * event still exists in the code. Darden is the worked example: its ecommerce events fired normally
10
+ * until a script-loading change on 2025-11-20 stopped them reaching GA4 for nine months. Every one
11
+ * of those days returns a real, honest zero from the API — a zero that means "not recorded", not
12
+ * "no sales". Without an outage recorded here, a yearly funnel would render that as a collapse in
13
+ * trade rather than as a gap in measurement.
14
+ *
8
15
  * This lives in the package rather than in each site repo so all three foundries share one model
9
16
  * and one set of semantics, and so the Studio panel and the server handler agree by construction.
10
17
  */
@@ -12,26 +19,74 @@
12
19
  import type { DateRange, MetricValue } from '../types'
13
20
  import { unavailable, partial } from '../types'
14
21
 
15
- /** An event that predates this work — long-running, with no discontinuity to mark. */
22
+ /** An event that predates this work — long-running, with no start discontinuity to mark. */
16
23
  export const PREEXISTING = 'preexisting' as const
17
24
 
18
25
  /**
19
- * When an event began firing on a site.
26
+ * A period during which an event stopped reaching GA4 despite still existing in the code.
27
+ * `until: null` means it is still broken as of now.
28
+ */
29
+ export interface EventOutage {
30
+ /** First ISO date with no data. */
31
+ start: string
32
+ /** ISO date it resumed, or null if unresolved. */
33
+ until: string | null
34
+ /** Short human-readable cause, shown in the UI next to the affected figure. */
35
+ reason?: string
36
+ }
37
+
38
+ /** An event with a start date and, optionally, periods where it stopped firing. */
39
+ export interface EventHistory {
40
+ /** When it began firing: `PREEXISTING`, or an ISO `YYYY-MM-DD` deploy date. */
41
+ from: typeof PREEXISTING | string
42
+ /** Periods where it stopped. Order does not matter. */
43
+ outages?: EventOutage[]
44
+ }
45
+
46
+ /**
47
+ * When an event fired on a site.
20
48
  * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.
21
- * An ISO `YYYY-MM-DD` string = the deploy date, set in the commit that ships the event.
49
+ * An ISO `YYYY-MM-DD` string = the deploy date. An `EventHistory` adds outages.
22
50
  */
23
- export type EventCutover = typeof PREEXISTING | string | null
51
+ export type EventCutover = typeof PREEXISTING | string | null | EventHistory
24
52
 
25
53
  /** How completely an event covers a requested range. */
26
54
  export type Coverage =
27
55
  | { status: 'full' }
28
- | { status: 'partial'; cutover: string }
29
- | { status: 'none'; reason: 'not_instrumented' | 'before_cutover' | 'unknown_event'; cutover?: string }
56
+ | {
57
+ status: 'partial'
58
+ reason: 'spans_cutover' | 'spans_outage'
59
+ /** Present when the range starts before the event was instrumented. */
60
+ cutover?: string
61
+ /** Outages overlapping the range. Empty for a pure cutover overlap. */
62
+ outages: EventOutage[]
63
+ }
64
+ | {
65
+ status: 'none'
66
+ reason: 'not_instrumented' | 'before_cutover' | 'unknown_event' | 'outage'
67
+ cutover?: string
68
+ /** Present when the whole range fell inside an outage. */
69
+ outages?: EventOutage[]
70
+ }
71
+
72
+ /** Normalise the shorthand forms into an EventHistory, or null when not instrumented. */
73
+ function toHistory(cutover: EventCutover | undefined): EventHistory | null {
74
+ if (cutover === null || cutover === undefined) return null
75
+ if (typeof cutover === 'string') return { from: cutover }
76
+ if (!cutover.from) return null
77
+ return cutover
78
+ }
79
+
80
+ /** Whether two inclusive date ranges overlap at all. */
81
+ function overlaps(aStart: string, aEnd: string | null, bStart: string, bEnd: string): boolean {
82
+ // A null end means "ongoing", so it extends past any range end.
83
+ return aStart <= bEnd && (aEnd === null || aEnd >= bStart)
84
+ }
30
85
 
31
86
  /**
32
87
  * Determine how well an event covers a date range.
33
88
  *
34
- * @param cutovers - the site's event cutover map
89
+ * @param cutovers - the site's event history map
35
90
  * @param eventName - GA4 event name to look up
36
91
  * @param range - the requested range
37
92
  */
@@ -42,17 +97,32 @@ export function coverageForRange(
42
97
  ): Coverage {
43
98
  if (!(eventName in cutovers)) return { status: 'none', reason: 'unknown_event' }
44
99
 
45
- const cutover = cutovers[eventName]
46
- // `in` proved the key exists, so undefined here means an explicitly undefined value.
47
- if (cutover === null || cutover === undefined) return { status: 'none', reason: 'not_instrumented' }
48
- if (cutover === PREEXISTING) return { status: 'full' }
100
+ const history = toHistory(cutovers[eventName])
101
+ if (!history) return { status: 'none', reason: 'not_instrumented' }
49
102
 
50
- const cutoverTime = Date.parse(cutover)
51
- // An unparseable date is a config error; treat it as unknown rather than silently trusting it.
52
- if (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }
103
+ // --- start date -----------------------------------------------------------
104
+ let cutover: string | undefined
105
+ let startsBeforeCutover = false
53
106
 
54
- if (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }
55
- if (Date.parse(range.start) < cutoverTime) return { status: 'partial', cutover }
107
+ if (history.from !== PREEXISTING) {
108
+ const cutoverTime = Date.parse(history.from)
109
+ // An unparseable date is a config error; treat it as unknown rather than silently trusting it.
110
+ if (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }
111
+
112
+ cutover = history.from
113
+ if (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }
114
+ startsBeforeCutover = Date.parse(range.start) < cutoverTime
115
+ }
116
+
117
+ // --- outages --------------------------------------------------------------
118
+ const hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end))
119
+
120
+ // A range sitting entirely inside one outage has no usable data at all.
121
+ const swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end))
122
+ if (swallowed) return { status: 'none', reason: 'outage', outages: [swallowed], cutover }
123
+
124
+ if (hit.length > 0) return { status: 'partial', reason: 'spans_outage', outages: hit, cutover }
125
+ if (startsBeforeCutover) return { status: 'partial', reason: 'spans_cutover', outages: [], cutover }
56
126
 
57
127
  return { status: 'full' }
58
128
  }
@@ -68,6 +138,10 @@ export function coverageForRange(
68
138
  */
69
139
  export function applyCoverage(count: number | null, coverage: Coverage): MetricValue {
70
140
  if (coverage.status === 'none') {
141
+ if (coverage.reason === 'outage') {
142
+ const outage = coverage.outages?.[0]
143
+ return unavailable('outage', outage ? describeOutage(outage) : undefined)
144
+ }
71
145
  return unavailable(
72
146
  coverage.reason === 'before_cutover' ? 'before_cutover' : 'not_instrumented',
73
147
  coverage.cutover ? `Instrumented from ${coverage.cutover}` : undefined,
@@ -77,12 +151,27 @@ export function applyCoverage(count: number | null, coverage: Coverage): MetricV
77
151
  if (count === null) return unavailable('source_error')
78
152
 
79
153
  if (coverage.status === 'partial') {
80
- return partial(count, coverage.cutover, `Only counted from ${coverage.cutover}, when this event was added`)
154
+ if (coverage.reason === 'spans_outage') {
155
+ const outage = coverage.outages[0]
156
+ // coveredFrom is the outage end where known, since that is when data resumes.
157
+ const from = outage?.until ?? outage?.start ?? coverage.cutover ?? ''
158
+ return partial(count, from, `Undercounted: ${describeOutage(outage)}`)
159
+ }
160
+
161
+ const from = coverage.cutover ?? ''
162
+ return partial(count, from, `Only counted from ${from}, when this event was added`)
81
163
  }
82
164
 
83
165
  return { status: 'ok', value: count }
84
166
  }
85
167
 
168
+ /** One-line description of an outage, for display beside an affected figure. */
169
+ export function describeOutage(outage: EventOutage | undefined): string {
170
+ if (!outage) return 'an outage affected part of this range'
171
+ const period = outage.until ? `${outage.start} to ${outage.until}` : `${outage.start} onwards`
172
+ return outage.reason ? `not recorded ${period} (${outage.reason})` : `not recorded ${period}`
173
+ }
174
+
86
175
  /**
87
176
  * Human-readable notices for every event in a range that is not fully covered.
88
177
  * Surfaced in the report envelope so the panel can show caveats without recomputing them.
@@ -96,8 +185,13 @@ export function coverageNotices(
96
185
 
97
186
  for (const name of eventNames) {
98
187
  const coverage = coverageForRange(cutovers, name, range)
99
- if (coverage.status === 'partial') {
188
+
189
+ if (coverage.status === 'partial' && coverage.reason === 'spans_outage') {
190
+ notices.push(`${name} was ${describeOutage(coverage.outages[0])}, so figures for this range are too low — not a real decline.`)
191
+ } else if (coverage.status === 'partial') {
100
192
  notices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`)
193
+ } else if (coverage.status === 'none' && coverage.reason === 'outage') {
194
+ notices.push(`${name} was ${describeOutage(coverage.outages?.[0])}, covering this whole range. The figure is unavailable, not zero.`)
101
195
  } else if (coverage.status === 'none' && coverage.reason === 'not_instrumented') {
102
196
  notices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`)
103
197
  } else if (coverage.status === 'none' && coverage.reason === 'before_cutover') {
package/src/server.ts CHANGED
@@ -23,7 +23,7 @@ export { runDiagnostics, type DiagnosticsInput } from './server/diagnostics'
23
23
 
24
24
  // Client constructors. Needed to call runDiagnostics headlessly, which the README documents —
25
25
  // without these exported that example could not actually be written.
26
- export { createGa4Client, type Ga4Client, type Ga4Report, type Ga4ReportRequest } from './server/ga4'
26
+ export { createGa4Client, eventNameFilter, sumFirstMetric, type Ga4Client, type Ga4Report, type Ga4ReportRequest } from './server/ga4'
27
27
  export { createVercelClient, type VercelClient, type VercelPageviews } from './server/vercel'
28
28
  export { parseServiceAccountKey, type ServiceAccountKey } from './server/googleAuth'
29
29
  export type { CheckStatus, DiagnosticCheck, DiagnosticReport } from './reportData'
@@ -18,6 +18,7 @@ const REASON_TEXT: Record<UnavailableReason, string> = {
18
18
  not_instrumented: 'Not tracked on this site',
19
19
  before_cutover: 'Not tracked during this period',
20
20
  suppressed: 'Withheld by GA4 for privacy',
21
+ outage: 'Not recorded during part of this period',
21
22
  source_error: 'Source did not respond',
22
23
  not_applicable: 'Does not apply to this site',
23
24
  }
package/src/types.ts CHANGED
@@ -16,6 +16,8 @@ export type UnavailableReason =
16
16
  | 'before_cutover'
17
17
  /** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */
18
18
  | 'suppressed'
19
+ /** The event exists in the code but stopped reaching GA4 for this period. Not a real zero. */
20
+ | 'outage'
19
21
  /** The upstream source errored or timed out. Retryable, unlike the others. */
20
22
  | 'source_error'
21
23
  /** The metric is not defined for this site at all (e.g. a flow that does not exist there). */