@canonry/canonry 4.141.0 → 4.142.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.
Files changed (25) hide show
  1. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +12 -5
  2. package/assets/assets/{AuditHistoryPanel-DlyLhzqq.js → AuditHistoryPanel-CfaF5B_e.js} +1 -1
  3. package/assets/assets/{BacklinksPage-DNxtwQBF.js → BacklinksPage-BIytWBpL.js} +1 -1
  4. package/assets/assets/{ChartPrimitives-BStLR_NC.js → ChartPrimitives-0tQT6GE7.js} +1 -1
  5. package/assets/assets/{HistoryPage-CIcCgoWK.js → HistoryPage-Bn6IH8WV.js} +1 -1
  6. package/assets/assets/{ProjectPage-BSI3Qum9.js → ProjectPage-DsQic9wX.js} +1 -1
  7. package/assets/assets/{RunRow-C0mFtLlc.js → RunRow-RHd8Bd52.js} +1 -1
  8. package/assets/assets/{RunsPage-C-yo0yoj.js → RunsPage-BsDqqQFX.js} +1 -1
  9. package/assets/assets/{SettingsPage-jg4MXOuo.js → SettingsPage-CX2LkFDB.js} +1 -1
  10. package/assets/assets/{TrafficPage-CnYHIg8t.js → TrafficPage-I19b7i9m.js} +1 -1
  11. package/assets/assets/{TrafficSourceDetailPage-CEdyQT2P.js → TrafficSourceDetailPage-DOLEAPsc.js} +1 -1
  12. package/assets/assets/{arrow-left-DUv0imIl.js → arrow-left-BG9o86__.js} +1 -1
  13. package/assets/assets/{extract-error-message-CCOGDwlm.js → extract-error-message-D0cgM2XG.js} +1 -1
  14. package/assets/assets/{index-D6EP4dQs.js → index-CYGBt2f0.js} +73 -73
  15. package/assets/assets/{trash-2-1Yg9W-vs.js → trash-2-C_THWxO_.js} +1 -1
  16. package/assets/index.html +1 -1
  17. package/dist/{chunk-ZCFQGF3V.js → chunk-D7NJUGNQ.js} +10 -7
  18. package/dist/{chunk-OJOA425V.js → chunk-LS6SEZVF.js} +78 -3
  19. package/dist/{chunk-2LUSZFB6.js → chunk-NR6OGEV3.js} +207 -49
  20. package/dist/{chunk-HXXO2AF5.js → chunk-UFPP5CSN.js} +4 -4
  21. package/dist/cli.js +62 -50
  22. package/dist/index.js +4 -4
  23. package/dist/{intelligence-service-4HQIUC7U.js → intelligence-service-EOUP37XN.js} +2 -2
  24. package/dist/mcp.js +2 -2
  25. package/package.json +7 -7
@@ -282,6 +282,7 @@ import {
282
282
  orderLocationsDefaultFirst,
283
283
  organicEvidenceDtoSchema,
284
284
  organicEvidencePeriodSchema,
285
+ parseBoundedRate,
285
286
  parseInclusiveEndMs,
286
287
  parseReportPeriodDays,
287
288
  parseRunError,
@@ -311,6 +312,7 @@ import {
311
312
  researchRunDetailSchema,
312
313
  researchRunListSchema,
313
314
  resolveConfigSpecQueries,
315
+ resolveDateRange,
314
316
  resolveLocations,
315
317
  resolveSnapshotRequestQueries,
316
318
  resultsExportDtoSchema,
@@ -380,7 +382,7 @@ import {
380
382
  wordpressSchemaDeployResultDtoSchema,
381
383
  wordpressSchemaStatusResultDtoSchema,
382
384
  wordpressStatusDtoSchema
383
- } from "./chunk-OJOA425V.js";
385
+ } from "./chunk-LS6SEZVF.js";
384
386
 
385
387
  // src/intelligence-service.ts
386
388
  import { eq as eq45, desc as desc22, asc as asc9, and as and36, ne as ne6, or as or10, inArray as inArray15, gte as gte12, lte as lte8 } from "drizzle-orm";
@@ -951,6 +953,23 @@ var gaDailyTotals = sqliteTable("ga_daily_totals", {
951
953
  date: text("date").notNull(),
952
954
  sessions: integer("sessions").notNull().default(0),
953
955
  users: integer("users").notNull().default(0),
956
+ /**
957
+ * GA4's `engagementRate` for the day (0-1), requested directly from the Data
958
+ * API. Nullable, with no default: every row written before the metric was
959
+ * added has no reading, and defaulting to 0 would report a real "nobody
960
+ * engaged" day for the whole pre-migration period.
961
+ */
962
+ engagementRate: real("engagement_rate"),
963
+ /**
964
+ * GA4's `newUsers` for the day. Nullable: rows written before this column
965
+ * existed have no reading, and a 0 would read as a real "no new users" day.
966
+ *
967
+ * Stored on its own merit. It is NOT an input to a returning-users figure:
968
+ * a visitor can be first-seen and return inside the same range, so
969
+ * `users - newUsers` does not reconstruct one. That needs the
970
+ * `newVsReturning` dimension.
971
+ */
972
+ newUsers: integer("new_users"),
954
973
  syncedAt: text("synced_at").notNull(),
955
974
  syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
956
975
  createdAt: text("created_at").notNull()
@@ -4443,6 +4462,25 @@ var MIGRATION_VERSIONS = [
4443
4462
  ON query_basket_versions(project_id, checksum)`,
4444
4463
  `ALTER TABLE runs ADD COLUMN query_basket_revision INTEGER`
4445
4464
  ]
4465
+ },
4466
+ {
4467
+ // GA4 engagement metrics on the property-level daily series.
4468
+ //
4469
+ // `engagement_rate` and `new_users` are both real GA4 metrics, requested
4470
+ // directly. No returning-users column: GA4 exposes no such metric, and
4471
+ // `users - new_users` does not reconstruct one because a visitor can be
4472
+ // first-seen AND return inside the same range. That needs the
4473
+ // `newVsReturning` dimension, which changes the row shape of the sync.
4474
+ //
4475
+ // Both columns are NULLABLE with no default. Every row written before this
4476
+ // migration has no reading, and NOT NULL DEFAULT 0 would turn that absence
4477
+ // into a real "0% engaged" day.
4478
+ version: 116,
4479
+ name: "ga-daily-totals-engagement",
4480
+ statements: [
4481
+ `ALTER TABLE ga_daily_totals ADD COLUMN engagement_rate REAL`,
4482
+ `ALTER TABLE ga_daily_totals ADD COLUMN new_users INTEGER`
4483
+ ]
4446
4484
  }
4447
4485
  ];
4448
4486
  function rebuildBacklinkTableWithSource(tx, table) {
@@ -15180,6 +15218,31 @@ function clickPeriods(periods, clicks, impressions) {
15180
15218
  impressions: impressions[index2] ?? 0
15181
15219
  }));
15182
15220
  }
15221
+ function engagementPeriods(periods, rows) {
15222
+ return periods.map((period2) => {
15223
+ const inPeriod = rows.filter((row) => row.date >= period2.startDate && row.date <= period2.endDate);
15224
+ const rateDays = inPeriod.filter((row) => row.engagementRate !== null);
15225
+ const weight = rateDays.reduce((sum, row) => sum + row.sessions, 0);
15226
+ const weighted = rateDays.reduce((sum, row) => sum + row.engagementRate * row.sessions, 0);
15227
+ const splitDays = inPeriod.filter((row) => row.newUsers !== null);
15228
+ const dailyTotalUsers = splitDays.reduce((sum, row) => sum + row.users, 0);
15229
+ const dailyNewUsers = splitDays.reduce((sum, row) => sum + row.newUsers, 0);
15230
+ return {
15231
+ ...period2,
15232
+ sessions: inPeriod.reduce((sum, row) => sum + row.sessions, 0),
15233
+ // A bucket whose reading-days had no sessions has no rate to report; the
15234
+ // weighted mean would be 0/0.
15235
+ engagementRate: rateDays.length > 0 && weight > 0 ? weighted / weight : null,
15236
+ dailyTotalUsers: splitDays.length > 0 ? dailyTotalUsers : null,
15237
+ dailyNewUsers: splitDays.length > 0 ? dailyNewUsers : null,
15238
+ // Null on a zero denominator too: a share of no users is not 0%.
15239
+ metricsAvailable: rateDays.length > 0 || splitDays.length > 0,
15240
+ daysInPeriod: inPeriod.length,
15241
+ daysWithEngagementRate: rateDays.length,
15242
+ daysWithUserSplit: splitDays.length
15243
+ };
15244
+ });
15245
+ }
15183
15246
  function rankEntries(entries) {
15184
15247
  const score = (values) => ({
15185
15248
  latest: values.at(-1) ?? 0,
@@ -15301,6 +15364,23 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
15301
15364
  const gaStartDate = gaPeriods[0]?.startDate;
15302
15365
  const acquisitionRows = gaStartDate ? db.select().from(gaAcquisitionDaily).where(and14(eq18(gaAcquisitionDaily.projectId, project.id), gte5(gaAcquisitionDaily.date, gaStartDate), lte2(gaAcquisitionDaily.date, gaAnchor))).all() : [];
15303
15366
  const leadRows = gaStartDate ? db.select().from(gaLeadEventsDaily).where(and14(eq18(gaLeadEventsDaily.projectId, project.id), gte5(gaLeadEventsDaily.date, gaStartDate), lte2(gaLeadEventsDaily.date, gaAnchor))).all() : [];
15367
+ const engagementAnchor = db.select({ date: sql9`max(${gaDailyTotals.date})` }).from(gaDailyTotals).where(eq18(gaDailyTotals.projectId, project.id)).get()?.date ?? null;
15368
+ const engagementPeriodWindow = engagementAnchor ? buildPeriods(engagementAnchor, days) : [];
15369
+ const engagementStartDate = engagementPeriodWindow[0]?.startDate;
15370
+ const engagementRows = engagementStartDate ? db.select().from(gaDailyTotals).where(and14(
15371
+ eq18(gaDailyTotals.projectId, project.id),
15372
+ gte5(gaDailyTotals.date, engagementStartDate),
15373
+ lte2(gaDailyTotals.date, engagementAnchor)
15374
+ )).all() : [];
15375
+ const engagementAvailableFrom = db.select({
15376
+ date: sql9`min(${gaDailyTotals.date})`
15377
+ }).from(gaDailyTotals).where(and14(
15378
+ eq18(gaDailyTotals.projectId, project.id),
15379
+ or4(
15380
+ sql9`${gaDailyTotals.engagementRate} is not null`,
15381
+ sql9`${gaDailyTotals.newUsers} is not null`
15382
+ )
15383
+ )).get()?.date ?? null;
15304
15384
  const gscAnchor = db.select({ date: sql9`max(${gscDailyTotals.date})` }).from(gscDailyTotals).where(eq18(gscDailyTotals.projectId, project.id)).get()?.date ?? null;
15305
15385
  const gscPeriods = gscAnchor ? buildPeriods(gscAnchor, days) : [];
15306
15386
  const gscStartDate = gscPeriods[0]?.startDate;
@@ -15460,6 +15540,12 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
15460
15540
  periods: eventPeriods(gaPeriods, values)
15461
15541
  }))
15462
15542
  },
15543
+ engagement: {
15544
+ status: engagementAnchor === null ? "unavailable" : "ready",
15545
+ availableFromDate: engagementAvailableFrom,
15546
+ latestDate: engagementAnchor,
15547
+ periods: engagementPeriods(engagementPeriodWindow, engagementRows)
15548
+ },
15463
15549
  searchDemand: gscAnchor === null ? {
15464
15550
  status: "unavailable",
15465
15551
  periods: [],
@@ -18108,9 +18194,21 @@ var reportAudienceQueryParameter = {
18108
18194
  var analyticsWindowParameter = {
18109
18195
  name: "window",
18110
18196
  in: "query",
18111
- description: "Time window for analytics queries.",
18197
+ description: "Time window for analytics queries. An unrecognised value is rejected with 400; it is never widened to the full history.",
18112
18198
  schema: { type: "string", enum: ["7d", "30d", "90d", "all"] }
18113
18199
  };
18200
+ var analyticsStartDateParameter = {
18201
+ name: "startDate",
18202
+ in: "query",
18203
+ description: 'Inclusive lower bound as a calendar date (YYYY-MM-DD). Takes precedence over "window", which is rolling from now and cannot name a calendar month.',
18204
+ schema: stringSchema
18205
+ };
18206
+ var analyticsEndDateParameter = {
18207
+ name: "endDate",
18208
+ in: "query",
18209
+ description: "Inclusive upper bound as a calendar date (YYYY-MM-DD).",
18210
+ schema: stringSchema
18211
+ };
18114
18212
  var organicEvidencePeriodQueryParameter = {
18115
18213
  name: "period",
18116
18214
  in: "query",
@@ -21564,7 +21662,7 @@ var routeCatalog = [
21564
21662
  path: "/api/v1/projects/{name}/ga/traffic",
21565
21663
  summary: "Get GA4 landing page traffic, channel breakdown, and AI referral landing pages",
21566
21664
  tags: ["ga4"],
21567
- parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter],
21665
+ parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21568
21666
  responses: {
21569
21667
  // TODO: Add `GaTrafficResponse` Zod schema in contracts.
21570
21668
  200: rawJsonResponse("GA4 traffic data returned.", looseObjectSchema),
@@ -21578,7 +21676,7 @@ var routeCatalog = [
21578
21676
  summary: "Get raw AI referral detail rows per day, landing page, and attribution dimension",
21579
21677
  description: "Detail rows, not totals. One row per landing page per attribution dimension, so a single day of one source is many rows and each is commonly worth one session. Use /ga/ai-referral-daily for per-date or per-source session counts.",
21580
21678
  tags: ["ga4"],
21581
- parameters: [nameParameter, analyticsWindowParameter],
21679
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21582
21680
  responses: {
21583
21681
  200: jsonArrayResponse("AI referral history returned.", "GA4AiReferralHistoryEntry"),
21584
21682
  400: errorResponse("GA4 is not connected."),
@@ -21591,7 +21689,7 @@ var routeCatalog = [
21591
21689
  summary: "Get AI referral sessions per day and per source",
21592
21690
  description: "Sums landing pages within one attribution dimension and never across dimensions, so totalSessions equals the aiSessionsDeduped reported by /ga/traffic for the same window. Sessions only: GA counts users as a distinct count at the grain requested, so an AI-referral user total cannot be summed from these rows and no un-dimensioned AI-referral fetch exists to supply one.",
21593
21691
  tags: ["ga4"],
21594
- parameters: [nameParameter, analyticsWindowParameter],
21692
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21595
21693
  responses: {
21596
21694
  200: jsonResponse("AI referral daily series returned.", "GA4AiReferralDailyDto"),
21597
21695
  400: errorResponse("GA4 is not connected."),
@@ -21603,7 +21701,7 @@ var routeCatalog = [
21603
21701
  path: "/api/v1/projects/{name}/ga/social-referral-history",
21604
21702
  summary: "Get social media referral sessions per day grouped by source",
21605
21703
  tags: ["ga4"],
21606
- parameters: [nameParameter, analyticsWindowParameter],
21704
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21607
21705
  responses: {
21608
21706
  200: jsonArrayResponse("Social referral history returned.", "GA4SocialReferralHistoryEntry"),
21609
21707
  400: errorResponse("GA4 is not connected."),
@@ -21641,7 +21739,7 @@ var routeCatalog = [
21641
21739
  path: "/api/v1/projects/{name}/ga/session-history",
21642
21740
  summary: "Get total sessions per day for the project",
21643
21741
  tags: ["ga4"],
21644
- parameters: [nameParameter, analyticsWindowParameter],
21742
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21645
21743
  responses: {
21646
21744
  200: jsonArrayResponse("Session history returned.", "GA4SessionHistoryEntry"),
21647
21745
  400: errorResponse("GA4 is not connected."),
@@ -24233,7 +24331,17 @@ var GA4_DIMENSIONS = {
24233
24331
  var GA4_METRICS = {
24234
24332
  sessions: "sessions",
24235
24333
  totalUsers: "totalUsers",
24236
- eventCount: "eventCount"
24334
+ eventCount: "eventCount",
24335
+ /** Share of sessions that were engaged. A real GA4 metric — request it directly. */
24336
+ engagementRate: "engagementRate",
24337
+ /**
24338
+ * First-time users, a real GA4 metric requested directly.
24339
+ *
24340
+ * Not an input to a returning-users figure: a visitor can be first-seen and
24341
+ * return inside the same range, so subtracting this from totalUsers does not
24342
+ * reconstruct one.
24343
+ */
24344
+ newUsers: "newUsers"
24237
24345
  };
24238
24346
 
24239
24347
  // ../integration-google-analytics/src/types.ts
@@ -24781,6 +24889,15 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
24781
24889
  ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
24782
24890
  return summary;
24783
24891
  }
24892
+ function parseOptionalMetric(value) {
24893
+ if (value === void 0 || value.trim() === "") return null;
24894
+ const parsed = Number(value);
24895
+ return Number.isFinite(parsed) ? parsed : null;
24896
+ }
24897
+ function parseOptionalCountMetric(value) {
24898
+ const parsed = parseOptionalMetric(value);
24899
+ return parsed === null ? null : Math.round(parsed);
24900
+ }
24784
24901
  async function fetchDailyTotals(accessToken, propertyId, days) {
24785
24902
  validateAccessToken2(accessToken);
24786
24903
  validatePropertyId(propertyId);
@@ -24792,14 +24909,25 @@ async function fetchDailyTotals(accessToken, propertyId, days) {
24792
24909
  const res = await runReport(accessToken, propertyId, {
24793
24910
  dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
24794
24911
  dimensions: [{ name: GA4_DIMENSIONS.date }],
24795
- metrics: [{ name: GA4_METRICS.sessions }, { name: GA4_METRICS.totalUsers }],
24912
+ metrics: [
24913
+ { name: GA4_METRICS.sessions },
24914
+ { name: GA4_METRICS.totalUsers },
24915
+ { name: GA4_METRICS.engagementRate },
24916
+ { name: GA4_METRICS.newUsers }
24917
+ ],
24796
24918
  limit: syncDays + 1
24797
24919
  });
24798
- const rows = (res.rows ?? []).map((row) => ({
24799
- date: compactDateToIso(row.dimensionValues[0]?.value ?? ""),
24800
- sessions: parseInt(row.metricValues[0]?.value ?? "0", 10) || 0,
24801
- users: parseInt(row.metricValues[1]?.value ?? "0", 10) || 0
24802
- })).filter((row) => row.date.length > 0);
24920
+ const rows = (res.rows ?? []).map((row) => {
24921
+ const users = parseInt(row.metricValues[1]?.value ?? "0", 10) || 0;
24922
+ const newUsers = parseOptionalCountMetric(row.metricValues[3]?.value);
24923
+ return {
24924
+ date: compactDateToIso(row.dimensionValues[0]?.value ?? ""),
24925
+ sessions: parseInt(row.metricValues[0]?.value ?? "0", 10) || 0,
24926
+ users,
24927
+ engagementRate: parseBoundedRate(parseOptionalMetric(row.metricValues[2]?.value)),
24928
+ newUsers
24929
+ };
24930
+ }).filter((row) => row.date.length > 0);
24803
24931
  ga4Log("info", "fetch-daily-totals.done", { propertyId, days: syncDays, rows: rows.length });
24804
24932
  return rows;
24805
24933
  }
@@ -32699,6 +32827,12 @@ function formatSharePct(numerator, total) {
32699
32827
  if (rounded === 0) return "<1%";
32700
32828
  return `${rounded}%`;
32701
32829
  }
32830
+ function dateRangeConditions(column, range) {
32831
+ const conditions = [];
32832
+ if (range.startDate) conditions.push(sql15`${column} >= ${range.startDate}`);
32833
+ if (range.endDate) conditions.push(sql15`${column} <= ${range.endDate}`);
32834
+ return conditions;
32835
+ }
32702
32836
  var SOCIAL_CHANNEL_GROUPS2 = /* @__PURE__ */ new Set(["Organic Social", "Paid Social"]);
32703
32837
  function buildChannelBreakdown(input) {
32704
32838
  const aiSessions = [...input.aiSessionsByChannelGroup.values()].reduce((sum, sessions) => sum + sessions, 0);
@@ -33155,6 +33289,12 @@ async function ga4Routes(app, opts) {
33155
33289
  date: row.date,
33156
33290
  sessions: row.sessions,
33157
33291
  users: row.users,
33292
+ // Stored as given, nulls included: an absent reading is not a 0%
33293
+ // engagement day. No returning-users figure is derived here —
33294
+ // it is derived from `users - newUsers` at read time so the stored
33295
+ // row keeps only what GA4 actually reported.
33296
+ engagementRate: row.engagementRate,
33297
+ newUsers: row.newUsers,
33158
33298
  syncedAt: now,
33159
33299
  syncRunId: runId,
33160
33300
  createdAt: now
@@ -33354,16 +33494,12 @@ async function ga4Routes(app, opts) {
33354
33494
  const project = resolveProject(app.db, request.params.name);
33355
33495
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33356
33496
  const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
33357
- const window = parseWindow(request.query.window);
33358
- const cutoff = windowCutoff(window);
33359
- const cutoffDate = cutoff?.slice(0, 10) ?? null;
33360
- const snapshotConditions = [eq32(gaTrafficSnapshots.projectId, project.id)];
33361
- if (cutoffDate) snapshotConditions.push(sql15`${gaTrafficSnapshots.date} >= ${cutoffDate}`);
33362
- const aiConditions = [eq32(gaAiReferrals.projectId, project.id)];
33363
- if (cutoffDate) aiConditions.push(sql15`${gaAiReferrals.date} >= ${cutoffDate}`);
33364
- const socialConditions = [eq32(gaSocialReferrals.projectId, project.id)];
33365
- if (cutoffDate) socialConditions.push(sql15`${gaSocialReferrals.date} >= ${cutoffDate}`);
33366
- const windowSummaryRow = cutoffDate ? app.db.select({
33497
+ const range = resolveDateRange(request.query);
33498
+ const dateFiltered = range.startDate !== null || range.endDate !== null;
33499
+ const snapshotConditions = [eq32(gaTrafficSnapshots.projectId, project.id), ...dateRangeConditions(gaTrafficSnapshots.date, range)];
33500
+ const aiConditions = [eq32(gaAiReferrals.projectId, project.id), ...dateRangeConditions(gaAiReferrals.date, range)];
33501
+ const socialConditions = [eq32(gaSocialReferrals.projectId, project.id), ...dateRangeConditions(gaSocialReferrals.date, range)];
33502
+ const windowSummaryRow = range.startDate && !range.explicitDates ? app.db.select({
33367
33503
  totalSessions: gaTrafficWindowSummaries.totalSessions,
33368
33504
  totalOrganicSessions: gaTrafficWindowSummaries.totalOrganicSessions,
33369
33505
  totalDirectSessions: gaTrafficWindowSummaries.totalDirectSessions,
@@ -33371,15 +33507,26 @@ async function ga4Routes(app, opts) {
33371
33507
  }).from(gaTrafficWindowSummaries).where(
33372
33508
  and26(
33373
33509
  eq32(gaTrafficWindowSummaries.projectId, project.id),
33374
- eq32(gaTrafficWindowSummaries.windowKey, window)
33510
+ eq32(gaTrafficWindowSummaries.windowKey, range.window)
33375
33511
  )
33376
33512
  ).get() : null;
33377
- const snapshotTotalsRow = cutoffDate && !windowSummaryRow ? app.db.select({
33378
- totalSessions: sql15`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)`,
33379
- totalOrganicSessions: sql15`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)`,
33380
- totalUsers: sql15`COALESCE(SUM(${gaTrafficSnapshots.users}), 0)`
33381
- }).from(gaTrafficSnapshots).where(and26(...snapshotConditions)).get() : null;
33382
- const summaryRow = cutoffDate ? windowSummaryRow ?? snapshotTotalsRow : app.db.select({
33513
+ const snapshotTotalsRow = dateFiltered && !windowSummaryRow ? (() => {
33514
+ const summed = app.db.select({
33515
+ totalSessions: sql15`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)`,
33516
+ totalOrganicSessions: sql15`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)`,
33517
+ totalUsers: sql15`COALESCE(SUM(${gaTrafficSnapshots.users}), 0)`
33518
+ }).from(gaTrafficSnapshots).where(and26(...snapshotConditions)).get();
33519
+ return {
33520
+ totalSessions: summed?.totalSessions ?? 0,
33521
+ totalOrganicSessions: summed?.totalOrganicSessions ?? 0,
33522
+ // Unavailable for an EXPLICIT calendar range. The rolling-window
33523
+ // fallback keeps its historical summed value: it is wrong for the
33524
+ // same reason, but it predates this branch and correcting it is a
33525
+ // separate, visible behaviour change rather than one bundled here.
33526
+ totalUsers: range.explicitDates ? null : summed?.totalUsers ?? 0
33527
+ };
33528
+ })() : null;
33529
+ const summaryRow = dateFiltered ? windowSummaryRow ?? snapshotTotalsRow : app.db.select({
33383
33530
  totalSessions: gaTrafficSummaries.totalSessions,
33384
33531
  totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
33385
33532
  totalUsers: gaTrafficSummaries.totalUsers
@@ -33464,7 +33611,11 @@ async function ga4Routes(app, opts) {
33464
33611
  totalSessions: total,
33465
33612
  totalOrganicSessions,
33466
33613
  totalDirectSessions,
33467
- totalUsers: summaryRow?.totalUsers ?? 0,
33614
+ // `null` (not 0) when the range has no un-dimensioned aggregate behind
33615
+ // it. GA counts users as a COUNT DISTINCT at the grain requested, so the
33616
+ // landing-page dimensioned sum is inflated, and a 0 would read as
33617
+ // "nobody visited" rather than "not measurable for this range".
33618
+ totalUsers: summaryRow ? summaryRow.totalUsers : 0,
33468
33619
  topPages: rows.map((r) => ({
33469
33620
  landingPage: r.landingPage,
33470
33621
  sessions: r.sessions ?? 0,
@@ -33531,21 +33682,32 @@ async function ga4Routes(app, opts) {
33531
33682
  directSharePctDisplay: formatSharePct(totalDirectSessions, total),
33532
33683
  socialSharePctDisplay: formatSharePct(socialSessions, total),
33533
33684
  lastSyncedAt: latestSync?.syncedAt ?? null,
33685
+ // Report the range that was actually measured, so a caller can tell which
33686
+ // period the totals above belong to.
33687
+ // An EXPLICIT calendar range is reported back verbatim, even when it
33688
+ // covers nothing. Substituting the synced period there labelled an empty
33689
+ // future range with real past dates, so the numbers said "no data" while
33690
+ // the labels named a period that did have data.
33691
+ //
33692
+ // A rolling window still clamps: "last 30 days" is a relative ask, and a
33693
+ // cutoff computed from today can legitimately land after the last synced
33694
+ // date on a stale project. Reporting a start after the end would be its
33695
+ // own nonsense.
33534
33696
  periodStart: (() => {
33535
- const start = cutoffDate ?? summaryMeta?.periodStart ?? null;
33536
- const end = summaryMeta?.periodEnd ?? null;
33697
+ if (range.explicitDates) return range.startDate ?? summaryMeta?.periodStart ?? null;
33698
+ const start = range.startDate ?? summaryMeta?.periodStart ?? null;
33699
+ const end = range.endDate ?? summaryMeta?.periodEnd ?? null;
33537
33700
  if (start && end && start > end) return summaryMeta?.periodStart ?? null;
33538
33701
  return start;
33539
33702
  })(),
33540
- periodEnd: summaryMeta?.periodEnd ?? null
33703
+ periodEnd: range.endDate ?? summaryMeta?.periodEnd ?? null
33541
33704
  };
33542
33705
  });
33543
33706
  app.get("/projects/:name/ga/ai-referral-history", async (request, _reply) => {
33544
33707
  const project = resolveProject(app.db, request.params.name);
33545
33708
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33546
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33547
- const conditions = [eq32(gaAiReferrals.projectId, project.id)];
33548
- if (cutoffDate) conditions.push(sql15`${gaAiReferrals.date} >= ${cutoffDate}`);
33709
+ const range = resolveDateRange(request.query);
33710
+ const conditions = [eq32(gaAiReferrals.projectId, project.id), ...dateRangeConditions(gaAiReferrals.date, range)];
33549
33711
  const rows = app.db.select({
33550
33712
  date: gaAiReferrals.date,
33551
33713
  source: gaAiReferrals.source,
@@ -33568,9 +33730,8 @@ async function ga4Routes(app, opts) {
33568
33730
  app.get("/projects/:name/ga/ai-referral-daily", async (request, _reply) => {
33569
33731
  const project = resolveProject(app.db, request.params.name);
33570
33732
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33571
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33572
- const conditions = [eq32(gaAiReferrals.projectId, project.id)];
33573
- if (cutoffDate) conditions.push(sql15`${gaAiReferrals.date} >= ${cutoffDate}`);
33733
+ const range = resolveDateRange(request.query);
33734
+ const conditions = [eq32(gaAiReferrals.projectId, project.id), ...dateRangeConditions(gaAiReferrals.date, range)];
33574
33735
  const rows = app.db.select({
33575
33736
  date: gaAiReferrals.date,
33576
33737
  source: gaAiReferrals.source,
@@ -33585,9 +33746,8 @@ async function ga4Routes(app, opts) {
33585
33746
  app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
33586
33747
  const project = resolveProject(app.db, request.params.name);
33587
33748
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33588
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33589
- const conditions = [eq32(gaSocialReferrals.projectId, project.id)];
33590
- if (cutoffDate) conditions.push(sql15`${gaSocialReferrals.date} >= ${cutoffDate}`);
33749
+ const range = resolveDateRange(request.query);
33750
+ const conditions = [eq32(gaSocialReferrals.projectId, project.id), ...dateRangeConditions(gaSocialReferrals.date, range)];
33591
33751
  const rows = app.db.select({
33592
33752
  date: gaSocialReferrals.date,
33593
33753
  source: gaSocialReferrals.source,
@@ -33730,17 +33890,15 @@ async function ga4Routes(app, opts) {
33730
33890
  app.get("/projects/:name/ga/session-history", async (request, _reply) => {
33731
33891
  const project = resolveProject(app.db, request.params.name);
33732
33892
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33733
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33734
- const conditions = [eq32(gaTrafficSnapshots.projectId, project.id)];
33735
- if (cutoffDate) conditions.push(sql15`${gaTrafficSnapshots.date} >= ${cutoffDate}`);
33893
+ const range = resolveDateRange(request.query);
33894
+ const conditions = [eq32(gaTrafficSnapshots.projectId, project.id), ...dateRangeConditions(gaTrafficSnapshots.date, range)];
33736
33895
  const rows = app.db.select({
33737
33896
  date: gaTrafficSnapshots.date,
33738
33897
  sessions: sql15`SUM(${gaTrafficSnapshots.sessions})`,
33739
33898
  organicSessions: sql15`SUM(${gaTrafficSnapshots.organicSessions})`,
33740
33899
  users: sql15`SUM(${gaTrafficSnapshots.users})`
33741
33900
  }).from(gaTrafficSnapshots).where(and26(...conditions)).groupBy(gaTrafficSnapshots.date).orderBy(gaTrafficSnapshots.date).all();
33742
- const totalConditions = [eq32(gaDailyTotals.projectId, project.id)];
33743
- if (cutoffDate) totalConditions.push(sql15`${gaDailyTotals.date} >= ${cutoffDate}`);
33901
+ const totalConditions = [eq32(gaDailyTotals.projectId, project.id), ...dateRangeConditions(gaDailyTotals.date, range)];
33744
33902
  const totals = app.db.select({ date: gaDailyTotals.date, users: gaDailyTotals.users }).from(gaDailyTotals).where(and26(...totalConditions)).all();
33745
33903
  return buildSessionHistory(
33746
33904
  rows.map((r) => ({
@@ -10,7 +10,7 @@ import {
10
10
  loadConfig,
11
11
  loadConfigRaw,
12
12
  saveConfigPatch
13
- } from "./chunk-ZCFQGF3V.js";
13
+ } from "./chunk-D7NJUGNQ.js";
14
14
  import {
15
15
  CC_CACHE_DIR,
16
16
  DUCKDB_SPEC,
@@ -123,7 +123,7 @@ import {
123
123
  siteAuditSnapshots,
124
124
  toAlertView,
125
125
  usageCounters
126
- } from "./chunk-2LUSZFB6.js";
126
+ } from "./chunk-NR6OGEV3.js";
127
127
  import {
128
128
  AGENT_MEMORY_VALUE_MAX_BYTES,
129
129
  AGENT_PROVIDER_IDS,
@@ -210,7 +210,7 @@ import {
210
210
  validationError,
211
211
  winnabilityClassLabel,
212
212
  withRetry
213
- } from "./chunk-OJOA425V.js";
213
+ } from "./chunk-LS6SEZVF.js";
214
214
 
215
215
  // src/telemetry.ts
216
216
  import crypto from "crypto";
@@ -7178,7 +7178,7 @@ function readStoredGroundingSources(rawResponse) {
7178
7178
  return result;
7179
7179
  }
7180
7180
  async function backfillInsightsCommand(project, opts) {
7181
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-4HQIUC7U.js");
7181
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-EOUP37XN.js");
7182
7182
  const config = loadConfig();
7183
7183
  const db = createClient(config.database);
7184
7184
  migrate(db);