@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.
@@ -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-BpDH6Vva.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, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-BpDH6Vva.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":[]}
@@ -89,19 +89,23 @@ declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga
89
89
  /**
90
90
  * Vercel Web Analytics client.
91
91
  *
92
- * Vercel's Web Analytics read API is not a stable, versioned public surface the way the GA4 Data
93
- * API is, and access depends on plan tier. This client is therefore written to degrade rather than
94
- * throw: a missing token, an unavailable plan, or a changed endpoint all surface as an unconfigured
95
- * or errored source in the report envelope, so the Measurement Health panel can say "Vercel did not
96
- * answer" instead of implying the site had no traffic.
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 here purely as a second, cookieless pageview measurement. It is never treated
99
- * as ground truth, and never subtracted from a GA4 session count — those are different units.
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
- * Vercel's Web Analytics read API is not a stable, versioned public surface the way the GA4 Data
93
- * API is, and access depends on plan tier. This client is therefore written to degrade rather than
94
- * throw: a missing token, an unavailable plan, or a changed endpoint all surface as an unconfigured
95
- * or errored source in the report envelope, so the Measurement Health panel can say "Vercel did not
96
- * answer" instead of implying the site had no traffic.
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 here purely as a second, cookieless pageview measurement. It is never treated
99
- * as ground truth, and never subtracted from a GA4 session count — those are different units.
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.1",
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",
@@ -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
  }
@@ -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