@liiift-studio/sanity-visitor-insights 0.2.0 → 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.
@@ -1,4 +1,4 @@
1
- import { G as Ga4Client, a as Ga4ReportRequest, S as SanityQueryClient, b as Ga4Report, c as VercelPageviews, V as VercelClient } from './vercel-D19ArNAY.mjs';
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
- /** Build a Vercel pageviews fixture. */
52
- declare function makeVercelPageviews(byDate: Record<string, number>): VercelPageviews;
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, a as Ga4ReportRequest, S as SanityQueryClient, b as Ga4Report, c as VercelPageviews, V as VercelClient } from './vercel-D19ArNAY.js';
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
- /** Build a Vercel pageviews fixture. */
52
- declare function makeVercelPageviews(byDate: Record<string, number>): VercelPageviews;
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: Object.values(byDate).reduce((sum, n) => sum + n, 0)
81
+ total,
82
+ visitors: visitors ?? Math.round(total * 0.66)
81
83
  };
82
84
  }
83
85
  function createFakeSanityClient(respond) {
@@ -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/** Build a Vercel pageviews fixture. */\nexport function makeVercelPageviews(byDate: Record<string, number>): VercelPageviews {\n\treturn {\n\t\tbyDate,\n\t\ttotal: Object.values(byDate).reduce((sum, n) => sum + n, 0),\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;AAGO,SAAS,oBAAoB,QAAiD;AACpF,SAAO;AAAA,IACN;AAAA,IACA,OAAO,OAAO,OAAO,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AAAA,EAC3D;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":[]}
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: Object.values(byDate).reduce((sum, n) => sum + n, 0)
49
+ total,
50
+ visitors: visitors ?? Math.round(total * 0.66)
49
51
  };
50
52
  }
51
53
  function createFakeSanityClient(respond) {
@@ -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/** Build a Vercel pageviews fixture. */\nexport function makeVercelPageviews(byDate: Record<string, number>): VercelPageviews {\n\treturn {\n\t\tbyDate,\n\t\ttotal: Object.values(byDate).reduce((sum, n) => sum + n, 0),\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;AAGO,SAAS,oBAAoB,QAAiD;AACpF,SAAO;AAAA,IACN;AAAA,IACA,OAAO,OAAO,OAAO,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AAAA,EAC3D;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":[]}
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":[]}
@@ -0,0 +1,123 @@
1
+ /** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
2
+ interface SanityQueryClient {
3
+ fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
4
+ }
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
+
30
+ /**
31
+ * GA4 Data API client.
32
+ *
33
+ * Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:
34
+ * it is an alpha surface with its own stricter quota, and it returns step-conversion marginals
35
+ * rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was
36
+ * never measured. The journey report approximates instead, and says so.
37
+ */
38
+
39
+ /** A GA4 report request, narrowed to the fields this package sets. */
40
+ interface Ga4ReportRequest {
41
+ dimensions?: Array<{
42
+ name: string;
43
+ }>;
44
+ metrics?: Array<{
45
+ name: string;
46
+ }>;
47
+ dateRanges: Array<{
48
+ startDate: string;
49
+ endDate: string;
50
+ }>;
51
+ dimensionFilter?: unknown;
52
+ orderBys?: unknown;
53
+ limit?: number;
54
+ keepEmptyRows?: boolean;
55
+ }
56
+ /** A parsed report row: dimension values and metric values, positionally aligned to the request. */
57
+ interface Ga4Row {
58
+ dimensions: string[];
59
+ metrics: number[];
60
+ }
61
+ /** A parsed GA4 report. */
62
+ interface Ga4Report {
63
+ rows: Ga4Row[];
64
+ /** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */
65
+ thresholded: boolean;
66
+ /** True when GA4 answered from a sample rather than the full data set. */
67
+ sampled: boolean;
68
+ /** Total row count GA4 reports, which may exceed rows returned when a limit applied. */
69
+ rowCount: number;
70
+ /**
71
+ * The property's configured timezone, as GA4 reports it. Worth capturing because every range
72
+ * is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.
73
+ */
74
+ timeZone?: string;
75
+ }
76
+ /** A GA4 client bound to one property. */
77
+ interface Ga4Client {
78
+ runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
79
+ batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
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;
88
+
89
+ /**
90
+ * Vercel Web Analytics client.
91
+ *
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
+ *
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.
102
+ */
103
+ /** Daily pageview counts, keyed by ISO date. */
104
+ interface VercelPageviews {
105
+ byDate: Record<string, number>;
106
+ total: number;
107
+ /** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
108
+ visitors: number;
109
+ }
110
+ /** A Vercel client bound to one project. */
111
+ interface VercelClient {
112
+ pageviews(start: string, end: string): Promise<VercelPageviews>;
113
+ }
114
+ /**
115
+ * Build a Vercel Web Analytics client.
116
+ *
117
+ * @param projectId - the Vercel project id
118
+ * @param token - a Vercel API token with read access to that project
119
+ * @param teamId - team scope, required when the project belongs to a team
120
+ */
121
+ declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
122
+
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 };
@@ -0,0 +1,123 @@
1
+ /** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
2
+ interface SanityQueryClient {
3
+ fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
4
+ }
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
+
30
+ /**
31
+ * GA4 Data API client.
32
+ *
33
+ * Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:
34
+ * it is an alpha surface with its own stricter quota, and it returns step-conversion marginals
35
+ * rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was
36
+ * never measured. The journey report approximates instead, and says so.
37
+ */
38
+
39
+ /** A GA4 report request, narrowed to the fields this package sets. */
40
+ interface Ga4ReportRequest {
41
+ dimensions?: Array<{
42
+ name: string;
43
+ }>;
44
+ metrics?: Array<{
45
+ name: string;
46
+ }>;
47
+ dateRanges: Array<{
48
+ startDate: string;
49
+ endDate: string;
50
+ }>;
51
+ dimensionFilter?: unknown;
52
+ orderBys?: unknown;
53
+ limit?: number;
54
+ keepEmptyRows?: boolean;
55
+ }
56
+ /** A parsed report row: dimension values and metric values, positionally aligned to the request. */
57
+ interface Ga4Row {
58
+ dimensions: string[];
59
+ metrics: number[];
60
+ }
61
+ /** A parsed GA4 report. */
62
+ interface Ga4Report {
63
+ rows: Ga4Row[];
64
+ /** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */
65
+ thresholded: boolean;
66
+ /** True when GA4 answered from a sample rather than the full data set. */
67
+ sampled: boolean;
68
+ /** Total row count GA4 reports, which may exceed rows returned when a limit applied. */
69
+ rowCount: number;
70
+ /**
71
+ * The property's configured timezone, as GA4 reports it. Worth capturing because every range
72
+ * is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.
73
+ */
74
+ timeZone?: string;
75
+ }
76
+ /** A GA4 client bound to one property. */
77
+ interface Ga4Client {
78
+ runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
79
+ batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
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;
88
+
89
+ /**
90
+ * Vercel Web Analytics client.
91
+ *
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
+ *
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.
102
+ */
103
+ /** Daily pageview counts, keyed by ISO date. */
104
+ interface VercelPageviews {
105
+ byDate: Record<string, number>;
106
+ total: number;
107
+ /** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
108
+ visitors: number;
109
+ }
110
+ /** A Vercel client bound to one project. */
111
+ interface VercelClient {
112
+ pageviews(start: string, end: string): Promise<VercelPageviews>;
113
+ }
114
+ /**
115
+ * Build a Vercel Web Analytics client.
116
+ *
117
+ * @param projectId - the Vercel project id
118
+ * @param token - a Vercel API token with read access to that project
119
+ * @param teamId - team scope, required when the project belongs to a team
120
+ */
121
+ declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
122
+
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 };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liiift-studio/sanity-visitor-insights",
3
- "version": "0.2.0",
4
- "description": "Visitor-behaviour analytics for type-foundry Sanity Studios \u2014 GA4, Vercel and order data reconciled honestly",
3
+ "version": "0.2.2",
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",
@@ -1,22 +1,26 @@
1
1
  /**
2
2
  * Vercel Web Analytics client.
3
3
  *
4
- * Vercel's Web Analytics read API is not a stable, versioned public surface the way the GA4 Data
5
- * API is, and access depends on plan tier. This client is therefore written to degrade rather than
6
- * throw: a missing token, an unavailable plan, or a changed endpoint all surface as an unconfigured
7
- * or errored source in the report envelope, so the Measurement Health panel can say "Vercel did not
8
- * answer" instead of implying the site had no traffic.
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 here purely as a second, cookieless pageview measurement. It is never treated
11
- * as ground truth, and never subtracted from a GA4 session count — those are different units.
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
- return {
36
- async pageviews(start, end) {
37
- const params = new URLSearchParams({
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
- const response = await fetch(`${API_BASE}/v1/web-analytics/timeseries?${params.toString()}`, {
45
- headers: { Authorization: `Bearer ${token}` },
46
- })
50
+ const response = await fetch(`${API_BASE}/${path}?${params.toString()}`, {
51
+ headers: { Authorization: `Bearer ${token}` },
52
+ })
47
53
 
48
- if (!response.ok) {
49
- throw new Error(`Vercel Web Analytics failed with ${response.status}`)
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
- const body = (await response.json()) as { data?: Array<{ key?: string; total?: number; devices?: number }> }
53
- const byDate: Record<string, number> = {}
54
- let total = 0
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
- for (const point of body.data ?? []) {
57
- if (!point.key) continue
58
- // `key` is a timestamp or date string depending on granularity; normalise to a date.
59
- const date = point.key.slice(0, 10)
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 { byDate, total }
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/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.
@@ -98,11 +98,19 @@ export function createFakeVercelClient(
98
98
  }
99
99
  }
100
100
 
101
- /** Build a Vercel pageviews fixture. */
102
- export function makeVercelPageviews(byDate: Record<string, number>): VercelPageviews {
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: Object.values(byDate).reduce((sum, n) => sum + n, 0),
112
+ total,
113
+ visitors: visitors ?? Math.round(total * 0.66),
106
114
  }
107
115
  }
108
116
 
@@ -1,80 +0,0 @@
1
- /** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
2
- interface SanityQueryClient {
3
- fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
4
- }
5
-
6
- /**
7
- * GA4 Data API client.
8
- *
9
- * Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:
10
- * it is an alpha surface with its own stricter quota, and it returns step-conversion marginals
11
- * rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was
12
- * never measured. The journey report approximates instead, and says so.
13
- */
14
-
15
- /** A GA4 report request, narrowed to the fields this package sets. */
16
- interface Ga4ReportRequest {
17
- dimensions?: Array<{
18
- name: string;
19
- }>;
20
- metrics?: Array<{
21
- name: string;
22
- }>;
23
- dateRanges: Array<{
24
- startDate: string;
25
- endDate: string;
26
- }>;
27
- dimensionFilter?: unknown;
28
- orderBys?: unknown;
29
- limit?: number;
30
- keepEmptyRows?: boolean;
31
- }
32
- /** A parsed report row: dimension values and metric values, positionally aligned to the request. */
33
- interface Ga4Row {
34
- dimensions: string[];
35
- metrics: number[];
36
- }
37
- /** A parsed GA4 report. */
38
- interface Ga4Report {
39
- rows: Ga4Row[];
40
- /** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */
41
- thresholded: boolean;
42
- /** True when GA4 answered from a sample rather than the full data set. */
43
- sampled: boolean;
44
- /** Total row count GA4 reports, which may exceed rows returned when a limit applied. */
45
- rowCount: number;
46
- /**
47
- * The property's configured timezone, as GA4 reports it. Worth capturing because every range
48
- * is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.
49
- */
50
- timeZone?: string;
51
- }
52
- /** A GA4 client bound to one property. */
53
- interface Ga4Client {
54
- runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
55
- batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
56
- }
57
-
58
- /**
59
- * Vercel Web Analytics client.
60
- *
61
- * Vercel's Web Analytics read API is not a stable, versioned public surface the way the GA4 Data
62
- * API is, and access depends on plan tier. This client is therefore written to degrade rather than
63
- * throw: a missing token, an unavailable plan, or a changed endpoint all surface as an unconfigured
64
- * or errored source in the report envelope, so the Measurement Health panel can say "Vercel did not
65
- * answer" instead of implying the site had no traffic.
66
- *
67
- * Vercel is counted here purely as a second, cookieless pageview measurement. It is never treated
68
- * as ground truth, and never subtracted from a GA4 session count — those are different units.
69
- */
70
- /** Daily pageview counts, keyed by ISO date. */
71
- interface VercelPageviews {
72
- byDate: Record<string, number>;
73
- total: number;
74
- }
75
- /** A Vercel client bound to one project. */
76
- interface VercelClient {
77
- pageviews(start: string, end: string): Promise<VercelPageviews>;
78
- }
79
-
80
- export type { Ga4Client as G, SanityQueryClient as S, VercelClient as V, Ga4ReportRequest as a, Ga4Report as b, VercelPageviews as c };