@liiift-studio/sanity-visitor-insights 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-Cc3Bv5-l.mjs';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-Cc3Bv5-l.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CWGvBi3C.mjs';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CWGvBi3C.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-Cc3Bv5-l.js';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-Cc3Bv5-l.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CWGvBi3C.js';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CWGvBi3C.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -227,7 +227,7 @@ declare function fromPageviews(ga4Pageviews: number, vercelPageviews: number): C
227
227
  * single known source, so a shortfall here that the others do not show points at attribution — a
228
228
  * tagging or redirect problem — rather than at measurement generally.
229
229
  */
230
- declare function fromEmail(ga4Sessions: number, mailchimpClicks: number): CaptureEstimate | null;
230
+ declare function fromEmail(ga4Users: number, mailchimpClicks: number): CaptureEstimate | null;
231
231
  /** The combined view of GA4's capture rate. */
232
232
  interface CaptureModel {
233
233
  /** Every estimate that had a usable denominator, best first. */
@@ -227,7 +227,7 @@ declare function fromPageviews(ga4Pageviews: number, vercelPageviews: number): C
227
227
  * single known source, so a shortfall here that the others do not show points at attribution — a
228
228
  * tagging or redirect problem — rather than at measurement generally.
229
229
  */
230
- declare function fromEmail(ga4Sessions: number, mailchimpClicks: number): CaptureEstimate | null;
230
+ declare function fromEmail(ga4Users: number, mailchimpClicks: number): CaptureEstimate | null;
231
231
  /** The combined view of GA4's capture rate. */
232
232
  interface CaptureModel {
233
233
  /** Every estimate that had a usable denominator, best first. */
package/dist/server.d.mts CHANGED
@@ -1,7 +1,7 @@
1
- import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-Cc3Bv5-l.mjs';
2
- export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-Cc3Bv5-l.mjs';
3
- import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-Da9lu56v.mjs';
4
- export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-Da9lu56v.mjs';
1
+ import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-CWGvBi3C.mjs';
2
+ export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-CWGvBi3C.mjs';
3
+ import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-CGDmz9Bt.mjs';
4
+ export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-CGDmz9Bt.mjs';
5
5
 
6
6
  /**
7
7
  * Request authentication and CORS for the report handler.
package/dist/server.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-Cc3Bv5-l.js';
2
- export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-Cc3Bv5-l.js';
3
- import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-Da9lu56v.js';
4
- export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-Da9lu56v.js';
1
+ import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-CWGvBi3C.js';
2
+ export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-CWGvBi3C.js';
3
+ import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-CGDmz9Bt.js';
4
+ export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-CGDmz9Bt.js';
5
5
 
6
6
  /**
7
7
  * Request authentication and CORS for the report handler.
package/dist/server.js CHANGED
@@ -874,9 +874,11 @@ function createMailchimpClient(apiKey, listId) {
874
874
  const month = range.start.slice(0, 7);
875
875
  const history = await get(
876
876
  `/lists/${listId}/growth-history/${month}`,
877
- { fields: "existing,imports,optins" }
877
+ { fields: "subscribed,existing,imports,optins" }
878
878
  );
879
- membersAtStart = count(history.existing) + count(history.imports) + count(history.optins);
879
+ const legacy = [history.existing, history.imports, history.optins];
880
+ const legacyTotal = legacy.some((v) => typeof v === "number") ? legacy.reduce((sum, v) => sum + count(v), 0) : null;
881
+ membersAtStart = typeof history.subscribed === "number" ? history.subscribed : legacyTotal;
880
882
  } catch {
881
883
  membersAtStart = null;
882
884
  }
@@ -887,8 +889,17 @@ function createMailchimpClient(apiKey, listId) {
887
889
  const reports = await get("/reports", {
888
890
  // Bounded by the range on both sides, so a panel showing one week does not pick up a
889
891
  // campaign from last year and attribute its clicks to this window.
890
- since_send_time: `${range.start}T00:00:00+00:00`,
891
- before_send_time: `${range.end}T23:59:59+00:00`,
892
+ /*
893
+ * Bounded in the PROPERTY timezone, like the orders are.
894
+ *
895
+ * These were bare UTC instants while `zonedDayStartUtc`/`zonedDayEndUtc` exist and
896
+ * orders.ts spends a paragraph on why that asks for a different window than the one GA4
897
+ * was asked for. For a US-Pacific property a campaign sent at 5pm local on the last day
898
+ * of the range fell outside this window while the GA4 sessions it produced fell inside —
899
+ * quietly shrinking the denominator of an estimate whose note calls it known exactly.
900
+ */
901
+ since_send_time: zonedDayStartUtc(range.start, range.timezone ?? "UTC"),
902
+ before_send_time: zonedDayEndUtc(range.end, range.timezone ?? "UTC"),
892
903
  /*
893
904
  * The documented maximum, and `total_items` so truncation can be SEEN.
894
905
  *
@@ -1198,14 +1209,14 @@ function fromPageviews(ga4Pageviews, vercelPageviews) {
1198
1209
  note: "GA4 pageviews against Vercel\u2019s. Read it as a ceiling: Vercel\u2019s own counter is a script too \u2014 blocked by fewer lists than GA4, but blocked \u2014 so real traffic is higher than both and GA4\u2019s true share is this or less."
1199
1210
  };
1200
1211
  }
1201
- function fromEmail(ga4Sessions, mailchimpClicks) {
1212
+ function fromEmail(ga4Users, mailchimpClicks) {
1202
1213
  if (mailchimpClicks < MIN_DENOMINATOR) return null;
1203
1214
  return {
1204
1215
  basis: "email",
1205
- rate: ga4Sessions / mailchimpClicks,
1206
- observed: ga4Sessions,
1216
+ rate: ga4Users / mailchimpClicks,
1217
+ observed: ga4Users,
1207
1218
  actual: mailchimpClicks,
1208
- note: "GA4 sessions from the campaign against Mailchimp\u2019s unique clicks. A cohort whose true size is known exactly."
1219
+ note: "Distinct people GA4 saw arriving from email, against the distinct subscribers Mailchimp says clicked. Both sides count people, so the comparison holds \u2014 but it measures tagging as much as capture."
1209
1220
  };
1210
1221
  }
1211
1222
  function samplingInterval(estimate) {
@@ -1235,13 +1246,16 @@ function describeDisagreement(estimates) {
1235
1246
  if (orders && pageviews && Math.abs(orders.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {
1236
1247
  return orders.rate > pageviews.rate ? "GA4 is capturing purchases far better than pageviews. That points at the pageview comparison rather than at a measurement failure \u2014 Vercel counts crawlers and prefetches GA4 never sees. Conversion figures here are more trustworthy than traffic figures." : "GA4 is capturing pageviews but missing purchases. That is not consent or ad-blocking, which would cost both equally \u2014 look at the purchase event itself: whether it fires on a page some buyers never reach, or is blocked at checkout.";
1237
1248
  }
1249
+ if (email && pageviews && Math.abs(email.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {
1250
+ return email.rate < pageviews.rate ? "Far fewer people are arriving from your mailing list than GA4\u2019s general capture would predict. That points at the campaign links rather than at measurement \u2014 check they carry UTM tags, and that no redirect is stripping them." : "Email traffic is over-represented against GA4\u2019s general capture rate, which usually means campaign links are tagged while ordinary traffic is not \u2014 so email looks larger than it is.";
1251
+ }
1238
1252
  if (email && orders && Math.abs(email.rate - orders.rate) > DISAGREEMENT_THRESHOLD) {
1239
1253
  return email.rate < orders.rate ? "Traffic from email is arriving less often than GA4\u2019s general capture rate would predict. That is an attribution problem rather than a measurement one \u2014 check the campaign links for missing UTMs, or a redirect stripping them." : "Email traffic is over-represented against GA4\u2019s general capture rate, which usually means campaign links are tagged while ordinary traffic is not, so email looks larger than it is.";
1240
1254
  }
1241
1255
  return null;
1242
1256
  }
1243
1257
  function grossUp(observed, model, admissible = ["orders", "email", "pageviews"]) {
1244
- const usable = model.estimates.filter((e) => admissible.includes(e.basis));
1258
+ const usable = model.estimates.filter((e) => admissible.includes(e.basis) && e.rate > 0 && e.rate < 1 && Number.isFinite(e.rate));
1245
1259
  if (usable.length === 0) return null;
1246
1260
  const rate = usable[0].rate;
1247
1261
  const rates = usable.map((e) => e.rate);
@@ -1367,7 +1381,11 @@ async function measurementHealth(input) {
1367
1381
  * what GA4 does not do.
1368
1382
  */
1369
1383
  keepEmptyRows: true,
1370
- limit: 400
1384
+ // A custom range may be 730 days, and this asked for 400 — so the tail simply did not
1385
+ // come back, drawing as absent on the chart whose job is dating when a gap opened.
1386
+ // Vercel has a whole mechanism for exactly this; GA4's own rowCount was parsed and
1387
+ // never checked here.
1388
+ limit: 800
1371
1389
  },
1372
1390
  {
1373
1391
  // The same-event numerator for the capture model: GA4's purchase count against
@@ -1381,13 +1399,22 @@ async function measurementHealth(input) {
1381
1399
  // Sessions GA4 attributes to email. The denominator side of this comes from
1382
1400
  // Mailchimp's unique subscriber clicks, so the two must describe the same
1383
1401
  // people — which they only do where campaign links carry a UTM.
1384
- metrics: [{ name: "sessions" }],
1402
+ // USERS, not sessions. Mailchimp counts distinct subscribers who clicked; one of them
1403
+ // arriving on two days is one click and two sessions, so dividing sessions by clicks
1404
+ // was not a capture rate at all — it was not even bounded by 1, on an estimate whose
1405
+ // own note calls it "a cohort whose true size is known exactly".
1406
+ metrics: [{ name: "totalUsers" }],
1385
1407
  dateRanges: [{ startDate: range.start, endDate: range.end }],
1386
1408
  dimensionFilter: {
1387
1409
  filter: { fieldName: "sessionMedium", stringFilter: { matchType: "EXACT", value: "email" } }
1388
1410
  }
1389
1411
  }
1390
1412
  ]);
1413
+ if (daily2 && daily2.rowCount > daily2.rows.length) {
1414
+ input.notices?.push(
1415
+ `Google Analytics returned ${daily2.rows.length} of ${daily2.rowCount} days for this range, so the day-by-day comparison is incomplete. Choose a shorter range.`
1416
+ );
1417
+ }
1391
1418
  if (daily2) {
1392
1419
  for (const row of daily2.rows) {
1393
1420
  const raw = row.dimensions[0];
@@ -1522,7 +1549,7 @@ async function measurementHealth(input) {
1522
1549
  try {
1523
1550
  const [list, sent] = await Promise.all([
1524
1551
  mailchimp.audience({ start: range.start, end: range.end }),
1525
- mailchimp.campaigns({ start: range.start, end: range.end })
1552
+ mailchimp.campaigns({ start: range.start, end: range.end, timezone: range.timezone })
1526
1553
  ]);
1527
1554
  audience = ok(list.members);
1528
1555
  audienceGrowth = list.membersAtStart === null ? unavailable("not_applicable", "Mailchimp reports list growth by calendar month, so this range has no start figure") : ok(list.members - list.membersAtStart);
@@ -1584,7 +1611,10 @@ async function measurementHealth(input) {
1584
1611
  revenue: revenueByDate ? revenueByDate[date] ?? 0 : null
1585
1612
  }));
1586
1613
  const timelineEvents = campaigns.filter((campaign) => campaign.sentAt).map((campaign) => ({
1587
- date: campaign.sentAt.slice(0, 10),
1614
+ // The day the send happened where the reader lives, matching the order stems it is meant
1615
+ // to be compared against. The UTC day put an evening send on the following day — the exact
1616
+ // misalignment orders.ts was zoned to prevent.
1617
+ date: zonedDay(campaign.sentAt, range.timezone) ?? campaign.sentAt.slice(0, 10),
1588
1618
  label: campaign.title,
1589
1619
  detail: `${formatInt(campaign.sent)} sent, ${formatInt(campaign.clicks)} clicked`
1590
1620
  }));
@@ -2397,8 +2427,11 @@ async function runDiagnostics(input) {
2397
2427
  checks.push({
2398
2428
  id: "vercel",
2399
2429
  label: "Vercel Web Analytics reachable",
2400
- status: "pass",
2401
- detail: `${result.total} pageviews in the last 7 days.`
2430
+ // A response carrying no total is not a healthy source. This reported `pass` and
2431
+ // rendered "null pageviews in the last 7 days" — a check that says nothing is wrong
2432
+ // while printing the word null is worse than no check.
2433
+ status: result.total === null ? "warn" : "pass",
2434
+ detail: result.total === null ? "Vercel answered but returned no pageview total, so its figures cannot be trusted for this range." : `${result.total} pageviews in the last 7 days.`
2402
2435
  });
2403
2436
  } catch (e) {
2404
2437
  checks.push({