@liiift-studio/sanity-visitor-insights 0.2.1 → 0.2.2
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 +2 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +32 -22
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +32 -22
- package/dist/server.mjs.map +1 -1
- package/dist/testing.d.mts +9 -3
- package/dist/testing.d.ts +9 -3
- package/dist/testing.js +4 -2
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +4 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{vercel-BpDH6Vva.d.mts → vercel-BMEO-Jcw.d.mts} +11 -7
- package/dist/{vercel-BpDH6Vva.d.ts → vercel-BMEO-Jcw.d.ts} +11 -7
- package/package.json +1 -1
- package/src/server/vercel.ts +59 -33
- package/src/testing/fakes.ts +11 -3
package/dist/testing.d.mts
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-
|
|
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';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
|
@@ -48,8 +48,14 @@ interface Ga4Script {
|
|
|
48
48
|
declare function createFakeGa4Client(script?: Ga4Script): FakeGa4Client;
|
|
49
49
|
/** Build a Vercel client fake returning a fixed total. */
|
|
50
50
|
declare function createFakeVercelClient(result: VercelPageviews | Error): VercelClient;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Build a Vercel pageviews fixture.
|
|
53
|
+
*
|
|
54
|
+
* `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,
|
|
55
|
+
* because they are genuinely different numbers — a fixture where they match would let a bug that
|
|
56
|
+
* confuses the two pass unnoticed.
|
|
57
|
+
*/
|
|
58
|
+
declare function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews;
|
|
53
59
|
/** A Sanity fake that also records the GROQ it was asked to run. */
|
|
54
60
|
interface FakeSanityClient extends SanityQueryClient {
|
|
55
61
|
/** Every query, with its params. Assert against this to prove no PII field was projected. */
|
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-
|
|
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';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
|
@@ -48,8 +48,14 @@ interface Ga4Script {
|
|
|
48
48
|
declare function createFakeGa4Client(script?: Ga4Script): FakeGa4Client;
|
|
49
49
|
/** Build a Vercel client fake returning a fixed total. */
|
|
50
50
|
declare function createFakeVercelClient(result: VercelPageviews | Error): VercelClient;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Build a Vercel pageviews fixture.
|
|
53
|
+
*
|
|
54
|
+
* `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,
|
|
55
|
+
* because they are genuinely different numbers — a fixture where they match would let a bug that
|
|
56
|
+
* confuses the two pass unnoticed.
|
|
57
|
+
*/
|
|
58
|
+
declare function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews;
|
|
53
59
|
/** A Sanity fake that also records the GROQ it was asked to run. */
|
|
54
60
|
interface FakeSanityClient extends SanityQueryClient {
|
|
55
61
|
/** Every query, with its params. Assert against this to prove no PII field was projected. */
|
package/dist/testing.js
CHANGED
|
@@ -74,10 +74,12 @@ function createFakeVercelClient(result) {
|
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
function makeVercelPageviews(byDate) {
|
|
77
|
+
function makeVercelPageviews(byDate, visitors) {
|
|
78
|
+
const total = Object.values(byDate).reduce((sum, n) => sum + n, 0);
|
|
78
79
|
return {
|
|
79
80
|
byDate,
|
|
80
|
-
total
|
|
81
|
+
total,
|
|
82
|
+
visitors: visitors ?? Math.round(total * 0.66)
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
function createFakeSanityClient(respond) {
|
package/dist/testing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing.ts","../src/testing/fakes.ts"],"sourcesContent":["/**\n * Testing entry point — test doubles for the report layer.\n *\n * Behind its own subpath so these never reach a production server or Studio bundle, while still\n * being available to a consuming site that wants to exercise these reports before it has\n * credentials to point at.\n *\n * import { createFakeGa4Client } from '@liiift-studio/sanity-visitor-insights/testing'\n */\n\nexport * from './testing/fakes'\n","/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../src/testing.ts","../src/testing/fakes.ts"],"sourcesContent":["/**\n * Testing entry point — test doubles for the report layer.\n *\n * Behind its own subpath so these never reach a production server or Studio bundle, while still\n * being available to a consuming site that wants to exercise these reports before it has\n * credentials to point at.\n *\n * import { createFakeGa4Client } from '@liiift-studio/sanity-visitor-insights/testing'\n */\n\nexport * from './testing/fakes'\n","/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n/**\n * Build a Vercel pageviews fixture.\n *\n * `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,\n * because they are genuinely different numbers — a fixture where they match would let a bug that\n * confuses the two pass unnoticed.\n */\nexport function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews {\n\tconst total = Object.values(byDate).reduce((sum, n) => sum + n, 0)\n\treturn {\n\t\tbyDate,\n\t\ttotal,\n\t\tvisitors: visitors ?? Math.round(total * 0.66),\n\t}\n}\n\n/** A Sanity fake that also records the GROQ it was asked to run. */\nexport interface FakeSanityClient extends SanityQueryClient {\n\t/** Every query, with its params. Assert against this to prove no PII field was projected. */\n\tqueries: Array<{ query: string; params?: Record<string, unknown> }>\n}\n\n/**\n * Build a Sanity client fake.\n *\n * @param respond - returns the documents for a given query, or throws to simulate failure\n */\nexport function createFakeSanityClient(respond: (query: string) => unknown): FakeSanityClient {\n\tconst queries: Array<{ query: string; params?: Record<string, unknown> }> = []\n\n\treturn {\n\t\tqueries,\n\t\tasync fetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {\n\t\t\tqueries.push({ query, params })\n\t\t\treturn respond(query) as T\n\t\t},\n\t}\n}\n\n/** Build order documents at given dates, in the shape the safe projection returns. */\nexport function makeOrders(dates: string[]): Array<{ _createdAt: string; orderStatus: string }> {\n\treturn dates.map((date) => ({ _createdAt: `${date}T12:00:00Z`, orderStatus: 'complete' }))\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACkBO,SAAS,cACf,MACA,UAAwD,CAAC,GAC7C;AACZ,QAAM,SAAmB,KAAK,IAAI,CAAC,SAAS;AAAA,IAC3C,YAAY,IAAI,cAAc,CAAC;AAAA,IAC/B,SAAS,IAAI;AAAA,EACd,EAAE;AAEF,SAAO;AAAA,IACN,MAAM;AAAA,IACN,aAAa,QAAQ,eAAe;AAAA,IACpC,SAAS,QAAQ,WAAW;AAAA,IAC5B,UAAU,OAAO;AAAA,EAClB;AACD;AAGO,SAAS,aAAa,OAA0B;AACtD,SAAO,cAAc,CAAC,EAAE,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC;AAC5C;AA0BO,SAAS,oBAAoB,SAAoB,CAAC,GAAkB;AAC1E,QAAM,aAAmC,CAAC;AAC1C,QAAM,cAAkC,CAAC;AAEzC,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IAEA,MAAM,gBAAgB,UAAU;AAC/B,iBAAW,KAAK,QAAQ;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,MAAO,QAAO,OAAO,MAAM,QAAQ;AAC9C,aAAO,SAAS,IAAI,MAAM,cAAc,CAAC,CAAC,CAAC;AAAA,IAC5C;AAAA,IAEA,MAAM,UAAU,SAAS;AACxB,kBAAY,KAAK,OAAO;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,OAAQ,QAAO,OAAO,OAAO,OAAO;AAC/C,aAAO,cAAc,CAAC,CAAC;AAAA,IACxB;AAAA,EACD;AACD;AAGO,SAAS,uBACf,QACe;AACf,SAAO;AAAA,IACN,MAAM,YAAY;AACjB,UAAI,kBAAkB,MAAO,OAAM;AACnC,aAAO;AAAA,IACR;AAAA,EACD;AACD;AASO,SAAS,oBAAoB,QAAgC,UAAoC;AACvG,QAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AACjE,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA,UAAU,YAAY,KAAK,MAAM,QAAQ,IAAI;AAAA,EAC9C;AACD;AAaO,SAAS,uBAAuB,SAAuD;AAC7F,QAAM,UAAsE,CAAC;AAE7E,SAAO;AAAA,IACN;AAAA,IACA,MAAM,MAAS,OAAe,QAA8C;AAC3E,cAAQ,KAAK,EAAE,OAAO,OAAO,CAAC;AAC9B,aAAO,QAAQ,KAAK;AAAA,IACrB;AAAA,EACD;AACD;AAGO,SAAS,WAAW,OAAqE;AAC/F,SAAO,MAAM,IAAI,CAAC,UAAU,EAAE,YAAY,GAAG,IAAI,cAAc,aAAa,WAAW,EAAE;AAC1F;","names":[]}
|
package/dist/testing.mjs
CHANGED
|
@@ -42,10 +42,12 @@ function createFakeVercelClient(result) {
|
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
function makeVercelPageviews(byDate) {
|
|
45
|
+
function makeVercelPageviews(byDate, visitors) {
|
|
46
|
+
const total = Object.values(byDate).reduce((sum, n) => sum + n, 0);
|
|
46
47
|
return {
|
|
47
48
|
byDate,
|
|
48
|
-
total
|
|
49
|
+
total,
|
|
50
|
+
visitors: visitors ?? Math.round(total * 0.66)
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
function createFakeSanityClient(respond) {
|
package/dist/testing.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing/fakes.ts"],"sourcesContent":["/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../src/testing/fakes.ts"],"sourcesContent":["/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n/**\n * Build a Vercel pageviews fixture.\n *\n * `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,\n * because they are genuinely different numbers — a fixture where they match would let a bug that\n * confuses the two pass unnoticed.\n */\nexport function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews {\n\tconst total = Object.values(byDate).reduce((sum, n) => sum + n, 0)\n\treturn {\n\t\tbyDate,\n\t\ttotal,\n\t\tvisitors: visitors ?? Math.round(total * 0.66),\n\t}\n}\n\n/** A Sanity fake that also records the GROQ it was asked to run. */\nexport interface FakeSanityClient extends SanityQueryClient {\n\t/** Every query, with its params. Assert against this to prove no PII field was projected. */\n\tqueries: Array<{ query: string; params?: Record<string, unknown> }>\n}\n\n/**\n * Build a Sanity client fake.\n *\n * @param respond - returns the documents for a given query, or throws to simulate failure\n */\nexport function createFakeSanityClient(respond: (query: string) => unknown): FakeSanityClient {\n\tconst queries: Array<{ query: string; params?: Record<string, unknown> }> = []\n\n\treturn {\n\t\tqueries,\n\t\tasync fetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {\n\t\t\tqueries.push({ query, params })\n\t\t\treturn respond(query) as T\n\t\t},\n\t}\n}\n\n/** Build order documents at given dates, in the shape the safe projection returns. */\nexport function makeOrders(dates: string[]): Array<{ _createdAt: string; orderStatus: string }> {\n\treturn dates.map((date) => ({ _createdAt: `${date}T12:00:00Z`, orderStatus: 'complete' }))\n}\n"],"mappings":";AAkBO,SAAS,cACf,MACA,UAAwD,CAAC,GAC7C;AACZ,QAAM,SAAmB,KAAK,IAAI,CAAC,SAAS;AAAA,IAC3C,YAAY,IAAI,cAAc,CAAC;AAAA,IAC/B,SAAS,IAAI;AAAA,EACd,EAAE;AAEF,SAAO;AAAA,IACN,MAAM;AAAA,IACN,aAAa,QAAQ,eAAe;AAAA,IACpC,SAAS,QAAQ,WAAW;AAAA,IAC5B,UAAU,OAAO;AAAA,EAClB;AACD;AAGO,SAAS,aAAa,OAA0B;AACtD,SAAO,cAAc,CAAC,EAAE,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC;AAC5C;AA0BO,SAAS,oBAAoB,SAAoB,CAAC,GAAkB;AAC1E,QAAM,aAAmC,CAAC;AAC1C,QAAM,cAAkC,CAAC;AAEzC,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IAEA,MAAM,gBAAgB,UAAU;AAC/B,iBAAW,KAAK,QAAQ;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,MAAO,QAAO,OAAO,MAAM,QAAQ;AAC9C,aAAO,SAAS,IAAI,MAAM,cAAc,CAAC,CAAC,CAAC;AAAA,IAC5C;AAAA,IAEA,MAAM,UAAU,SAAS;AACxB,kBAAY,KAAK,OAAO;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,OAAQ,QAAO,OAAO,OAAO,OAAO;AAC/C,aAAO,cAAc,CAAC,CAAC;AAAA,IACxB;AAAA,EACD;AACD;AAGO,SAAS,uBACf,QACe;AACf,SAAO;AAAA,IACN,MAAM,YAAY;AACjB,UAAI,kBAAkB,MAAO,OAAM;AACnC,aAAO;AAAA,IACR;AAAA,EACD;AACD;AASO,SAAS,oBAAoB,QAAgC,UAAoC;AACvG,QAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AACjE,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA,UAAU,YAAY,KAAK,MAAM,QAAQ,IAAI;AAAA,EAC9C;AACD;AAaO,SAAS,uBAAuB,SAAuD;AAC7F,QAAM,UAAsE,CAAC;AAE7E,SAAO;AAAA,IACN;AAAA,IACA,MAAM,MAAS,OAAe,QAA8C;AAC3E,cAAQ,KAAK,EAAE,OAAO,OAAO,CAAC;AAC9B,aAAO,QAAQ,KAAK;AAAA,IACrB;AAAA,EACD;AACD;AAGO,SAAS,WAAW,OAAqE;AAC/F,SAAO,MAAM,IAAI,CAAC,UAAU,EAAE,YAAY,GAAG,IAAI,cAAc,aAAa,WAAW,EAAE;AAC1F;","names":[]}
|
|
@@ -89,19 +89,23 @@ declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga
|
|
|
89
89
|
/**
|
|
90
90
|
* Vercel Web Analytics client.
|
|
91
91
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
92
|
+
* Uses the documented Web Analytics query API under `/v1/query/web-analytics/visits/*`. An earlier
|
|
93
|
+
* version of this file guessed at `/v1/web-analytics/timeseries`, which does not exist and returned
|
|
94
|
+
* 404 for every project — indistinguishable, from the outside, from the feature being switched off.
|
|
95
|
+
* That mistake is why `unavailable` carries a reason: a 404 here now surfaces as a source error to
|
|
96
|
+
* be investigated rather than as an absence of traffic.
|
|
97
97
|
*
|
|
98
|
-
* Vercel is counted
|
|
99
|
-
*
|
|
98
|
+
* Vercel is counted purely as a second, cookieless pageview measurement. It is never treated as
|
|
99
|
+
* ground truth, and never subtracted from a GA4 session count — those are different units.
|
|
100
|
+
*
|
|
101
|
+
* Note the API defaults to the production environment only, which is what the reports want.
|
|
100
102
|
*/
|
|
101
103
|
/** Daily pageview counts, keyed by ISO date. */
|
|
102
104
|
interface VercelPageviews {
|
|
103
105
|
byDate: Record<string, number>;
|
|
104
106
|
total: number;
|
|
107
|
+
/** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
|
|
108
|
+
visitors: number;
|
|
105
109
|
}
|
|
106
110
|
/** A Vercel client bound to one project. */
|
|
107
111
|
interface VercelClient {
|
|
@@ -89,19 +89,23 @@ declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga
|
|
|
89
89
|
/**
|
|
90
90
|
* Vercel Web Analytics client.
|
|
91
91
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
92
|
+
* Uses the documented Web Analytics query API under `/v1/query/web-analytics/visits/*`. An earlier
|
|
93
|
+
* version of this file guessed at `/v1/web-analytics/timeseries`, which does not exist and returned
|
|
94
|
+
* 404 for every project — indistinguishable, from the outside, from the feature being switched off.
|
|
95
|
+
* That mistake is why `unavailable` carries a reason: a 404 here now surfaces as a source error to
|
|
96
|
+
* be investigated rather than as an absence of traffic.
|
|
97
97
|
*
|
|
98
|
-
* Vercel is counted
|
|
99
|
-
*
|
|
98
|
+
* Vercel is counted purely as a second, cookieless pageview measurement. It is never treated as
|
|
99
|
+
* ground truth, and never subtracted from a GA4 session count — those are different units.
|
|
100
|
+
*
|
|
101
|
+
* Note the API defaults to the production environment only, which is what the reports want.
|
|
100
102
|
*/
|
|
101
103
|
/** Daily pageview counts, keyed by ISO date. */
|
|
102
104
|
interface VercelPageviews {
|
|
103
105
|
byDate: Record<string, number>;
|
|
104
106
|
total: number;
|
|
107
|
+
/** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
|
|
108
|
+
visitors: number;
|
|
105
109
|
}
|
|
106
110
|
/** A Vercel client bound to one project. */
|
|
107
111
|
interface VercelClient {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/sanity-visitor-insights",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
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",
|
package/src/server/vercel.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vercel Web Analytics client.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Uses the documented Web Analytics query API under `/v1/query/web-analytics/visits/*`. An earlier
|
|
5
|
+
* version of this file guessed at `/v1/web-analytics/timeseries`, which does not exist and returned
|
|
6
|
+
* 404 for every project — indistinguishable, from the outside, from the feature being switched off.
|
|
7
|
+
* That mistake is why `unavailable` carries a reason: a 404 here now surfaces as a source error to
|
|
8
|
+
* be investigated rather than as an absence of traffic.
|
|
9
9
|
*
|
|
10
|
-
* Vercel is counted
|
|
11
|
-
*
|
|
10
|
+
* Vercel is counted purely as a second, cookieless pageview measurement. It is never treated as
|
|
11
|
+
* ground truth, and never subtracted from a GA4 session count — those are different units.
|
|
12
|
+
*
|
|
13
|
+
* Note the API defaults to the production environment only, which is what the reports want.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
const API_BASE = 'https://api.vercel.com'
|
|
16
|
+
const API_BASE = 'https://api.vercel.com/v1/query/web-analytics'
|
|
15
17
|
|
|
16
18
|
/** Daily pageview counts, keyed by ISO date. */
|
|
17
19
|
export interface VercelPageviews {
|
|
18
20
|
byDate: Record<string, number>
|
|
19
21
|
total: number
|
|
22
|
+
/** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
|
|
23
|
+
visitors: number
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/** A Vercel client bound to one project. */
|
|
@@ -24,6 +28,13 @@ export interface VercelClient {
|
|
|
24
28
|
pageviews(start: string, end: string): Promise<VercelPageviews>
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/** Shape of the aggregate response rows. */
|
|
32
|
+
interface AggregateRow {
|
|
33
|
+
timestamp?: string
|
|
34
|
+
pageviews?: number
|
|
35
|
+
visitors?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
/**
|
|
28
39
|
* Build a Vercel Web Analytics client.
|
|
29
40
|
*
|
|
@@ -32,37 +43,52 @@ export interface VercelClient {
|
|
|
32
43
|
* @param teamId - team scope, required when the project belongs to a team
|
|
33
44
|
*/
|
|
34
45
|
export function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
projectId,
|
|
39
|
-
since: `${start}T00:00:00.000Z`,
|
|
40
|
-
until: `${end}T23:59:59.999Z`,
|
|
41
|
-
})
|
|
42
|
-
if (teamId) params.set('teamId', teamId)
|
|
46
|
+
async function query<T>(path: string, extra: Record<string, string>): Promise<T> {
|
|
47
|
+
const params = new URLSearchParams({ projectId, ...extra })
|
|
48
|
+
if (teamId) params.set('teamId', teamId)
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
const response = await fetch(`${API_BASE}/${path}?${params.toString()}`, {
|
|
51
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
52
|
+
})
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
// 404 here means the endpoint or project is wrong, or Web Analytics is not enabled —
|
|
56
|
+
// the status is the only useful detail, and the body can echo the request.
|
|
57
|
+
throw new Error(`Vercel Web Analytics ${path} failed with ${response.status}`)
|
|
58
|
+
}
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
return (await response.json()) as T
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
async pageviews(start, end) {
|
|
65
|
+
// Two calls on purpose. The count endpoint gives range totals with visitors deduped
|
|
66
|
+
// across the whole window; summing the daily rows would overcount visitors, because a
|
|
67
|
+
// person returning on three days is three daily visitors but one range visitor.
|
|
68
|
+
const [totals, series] = await Promise.all([
|
|
69
|
+
query<{ data?: { pageviews?: number; visitors?: number } }>('visits/count', {
|
|
70
|
+
since: start,
|
|
71
|
+
until: end,
|
|
72
|
+
}),
|
|
73
|
+
query<{ data?: AggregateRow[] }>('visits/aggregate', {
|
|
74
|
+
since: start,
|
|
75
|
+
until: end,
|
|
76
|
+
by: 'day',
|
|
77
|
+
limit: '100',
|
|
78
|
+
}),
|
|
79
|
+
])
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const count = typeof point.total === 'number' ? point.total : 0
|
|
61
|
-
byDate[date] = (byDate[date] ?? 0) + count
|
|
62
|
-
total += count
|
|
81
|
+
const byDate: Record<string, number> = {}
|
|
82
|
+
for (const row of series.data ?? []) {
|
|
83
|
+
if (!row.timestamp) continue
|
|
84
|
+
byDate[row.timestamp.slice(0, 10)] = row.pageviews ?? 0
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
byDate,
|
|
89
|
+
total: totals.data?.pageviews ?? 0,
|
|
90
|
+
visitors: totals.data?.visitors ?? 0,
|
|
91
|
+
}
|
|
66
92
|
},
|
|
67
93
|
}
|
|
68
94
|
}
|
package/src/testing/fakes.ts
CHANGED
|
@@ -98,11 +98,19 @@ export function createFakeVercelClient(
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Build a Vercel pageviews fixture.
|
|
103
|
+
*
|
|
104
|
+
* `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,
|
|
105
|
+
* because they are genuinely different numbers — a fixture where they match would let a bug that
|
|
106
|
+
* confuses the two pass unnoticed.
|
|
107
|
+
*/
|
|
108
|
+
export function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews {
|
|
109
|
+
const total = Object.values(byDate).reduce((sum, n) => sum + n, 0)
|
|
103
110
|
return {
|
|
104
111
|
byDate,
|
|
105
|
-
total
|
|
112
|
+
total,
|
|
113
|
+
visitors: visitors ?? Math.round(total * 0.66),
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
|