@canonry/canonry 4.139.1 → 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 (30) hide show
  1. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +33 -5
  2. package/assets/agent-workspace/skills/canonry/references/indexing.md +2 -1
  3. package/assets/assets/{AuditHistoryPanel-FiMbN4_a.js → AuditHistoryPanel-CfaF5B_e.js} +1 -1
  4. package/assets/assets/{BacklinksPage-CLRIsKHN.js → BacklinksPage-BIytWBpL.js} +1 -1
  5. package/assets/assets/{ChartPrimitives-BuTqG5NR.js → ChartPrimitives-0tQT6GE7.js} +1 -1
  6. package/assets/assets/{HistoryPage-BWRbsFAT.js → HistoryPage-Bn6IH8WV.js} +1 -1
  7. package/assets/assets/{ProjectPage-DQ2kIj-L.js → ProjectPage-DsQic9wX.js} +8 -8
  8. package/assets/assets/{RunRow-BZobaeDq.js → RunRow-RHd8Bd52.js} +1 -1
  9. package/assets/assets/{RunsPage-DhrY4Ouc.js → RunsPage-BsDqqQFX.js} +1 -1
  10. package/assets/assets/{SettingsPage-C6VcJEgB.js → SettingsPage-CX2LkFDB.js} +1 -1
  11. package/assets/assets/{TrafficPage-KqYl5HUH.js → TrafficPage-I19b7i9m.js} +1 -1
  12. package/assets/assets/{TrafficSourceDetailPage-mMJi-QZr.js → TrafficSourceDetailPage-DOLEAPsc.js} +1 -1
  13. package/assets/assets/{arrow-left-CpLow2Kp.js → arrow-left-BG9o86__.js} +1 -1
  14. package/assets/assets/{extract-error-message-dHq11Y4U.js → extract-error-message-D0cgM2XG.js} +1 -1
  15. package/assets/assets/index-BDo9nl4O.css +1 -0
  16. package/assets/assets/index-CYGBt2f0.js +213 -0
  17. package/assets/assets/{trash-2-CnEPNBqu.js → trash-2-C_THWxO_.js} +1 -1
  18. package/assets/index.html +2 -2
  19. package/dist/{chunk-IYTHIAAE.js → chunk-D7NJUGNQ.js} +55 -10
  20. package/dist/{chunk-OA2EGCUS.js → chunk-LS6SEZVF.js} +120 -4
  21. package/dist/{chunk-DJHSOPMU.js → chunk-NR6OGEV3.js} +326 -67
  22. package/dist/{chunk-YLHZQ7HQ.js → chunk-UFPP5CSN.js} +16 -5
  23. package/dist/cli.js +203 -63
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +4 -4
  26. package/dist/{intelligence-service-BGH2MGHB.js → intelligence-service-EOUP37XN.js} +2 -2
  27. package/dist/mcp.js +2 -2
  28. package/package.json +6 -6
  29. package/assets/assets/index-CntuM6Th.css +0 -1
  30. package/assets/assets/index-iRW7X23W.js +0 -213
@@ -240,11 +240,13 @@ import {
240
240
  gscDeindexedRowSchema,
241
241
  gscDiscoverSitemapsResponseDtoSchema,
242
242
  gscPerformanceDailyDtoSchema,
243
- gscSearchDataDtoSchema,
243
+ gscPerformanceOrderBySchema,
244
+ gscPerformanceResponseDtoSchema,
244
245
  gscSiteListResponseDtoSchema,
245
246
  gscSitemapListResponseDtoSchema,
246
247
  gscSubmitSitemapsRequestDtoSchema,
247
248
  gscSubmitSitemapsResponseDtoSchema,
249
+ gscTopPagesDtoSchema,
248
250
  gscUrlInspectionDtoSchema,
249
251
  hasLocationLabel,
250
252
  healthSnapshotDtoSchema,
@@ -280,6 +282,7 @@ import {
280
282
  orderLocationsDefaultFirst,
281
283
  organicEvidenceDtoSchema,
282
284
  organicEvidencePeriodSchema,
285
+ parseBoundedRate,
283
286
  parseInclusiveEndMs,
284
287
  parseReportPeriodDays,
285
288
  parseRunError,
@@ -309,6 +312,7 @@ import {
309
312
  researchRunDetailSchema,
310
313
  researchRunListSchema,
311
314
  resolveConfigSpecQueries,
315
+ resolveDateRange,
312
316
  resolveLocations,
313
317
  resolveSnapshotRequestQueries,
314
318
  resultsExportDtoSchema,
@@ -378,7 +382,7 @@ import {
378
382
  wordpressSchemaDeployResultDtoSchema,
379
383
  wordpressSchemaStatusResultDtoSchema,
380
384
  wordpressStatusDtoSchema
381
- } from "./chunk-OA2EGCUS.js";
385
+ } from "./chunk-LS6SEZVF.js";
382
386
 
383
387
  // src/intelligence-service.ts
384
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";
@@ -949,6 +953,23 @@ var gaDailyTotals = sqliteTable("ga_daily_totals", {
949
953
  date: text("date").notNull(),
950
954
  sessions: integer("sessions").notNull().default(0),
951
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"),
952
973
  syncedAt: text("synced_at").notNull(),
953
974
  syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
954
975
  createdAt: text("created_at").notNull()
@@ -4441,6 +4462,25 @@ var MIGRATION_VERSIONS = [
4441
4462
  ON query_basket_versions(project_id, checksum)`,
4442
4463
  `ALTER TABLE runs ADD COLUMN query_basket_revision INTEGER`
4443
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
+ ]
4444
4484
  }
4445
4485
  ];
4446
4486
  function rebuildBacklinkTableWithSource(tx, table) {
@@ -15178,6 +15218,31 @@ function clickPeriods(periods, clicks, impressions) {
15178
15218
  impressions: impressions[index2] ?? 0
15179
15219
  }));
15180
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
+ }
15181
15246
  function rankEntries(entries) {
15182
15247
  const score = (values) => ({
15183
15248
  latest: values.at(-1) ?? 0,
@@ -15299,6 +15364,23 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
15299
15364
  const gaStartDate = gaPeriods[0]?.startDate;
15300
15365
  const acquisitionRows = gaStartDate ? db.select().from(gaAcquisitionDaily).where(and14(eq18(gaAcquisitionDaily.projectId, project.id), gte5(gaAcquisitionDaily.date, gaStartDate), lte2(gaAcquisitionDaily.date, gaAnchor))).all() : [];
15301
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;
15302
15384
  const gscAnchor = db.select({ date: sql9`max(${gscDailyTotals.date})` }).from(gscDailyTotals).where(eq18(gscDailyTotals.projectId, project.id)).get()?.date ?? null;
15303
15385
  const gscPeriods = gscAnchor ? buildPeriods(gscAnchor, days) : [];
15304
15386
  const gscStartDate = gscPeriods[0]?.startDate;
@@ -15458,6 +15540,12 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
15458
15540
  periods: eventPeriods(gaPeriods, values)
15459
15541
  }))
15460
15542
  },
15543
+ engagement: {
15544
+ status: engagementAnchor === null ? "unavailable" : "ready",
15545
+ availableFromDate: engagementAvailableFrom,
15546
+ latestDate: engagementAnchor,
15547
+ periods: engagementPeriods(engagementPeriodWindow, engagementRows)
15548
+ },
15461
15549
  searchDemand: gscAnchor === null ? {
15462
15550
  status: "unavailable",
15463
15551
  periods: [],
@@ -17766,7 +17854,8 @@ var SCHEMA_TABLE = {
17766
17854
  GscCoverageSummaryDto: gscCoverageSummaryDtoSchema,
17767
17855
  GscDeindexedRowDto: gscDeindexedRowSchema,
17768
17856
  GscPerformanceDailyDto: gscPerformanceDailyDtoSchema,
17769
- GscSearchDataDto: gscSearchDataDtoSchema,
17857
+ GscPerformanceResponseDto: gscPerformanceResponseDtoSchema,
17858
+ GscTopPagesDto: gscTopPagesDtoSchema,
17770
17859
  GscDiscoverSitemapsResponseDto: gscDiscoverSitemapsResponseDtoSchema,
17771
17860
  GscSiteListResponseDto: gscSiteListResponseDtoSchema,
17772
17861
  GscSubmitSitemapsResponseDto: gscSubmitSitemapsResponseDtoSchema,
@@ -18105,9 +18194,21 @@ var reportAudienceQueryParameter = {
18105
18194
  var analyticsWindowParameter = {
18106
18195
  name: "window",
18107
18196
  in: "query",
18108
- 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.",
18109
18198
  schema: { type: "string", enum: ["7d", "30d", "90d", "all"] }
18110
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
+ };
18111
18212
  var organicEvidencePeriodQueryParameter = {
18112
18213
  name: "period",
18113
18214
  in: "query",
@@ -19717,15 +19818,22 @@ var routeCatalog = [
19717
19818
  { name: "endDate", in: "query", description: "Filter by end date.", schema: stringSchema },
19718
19819
  { name: "query", in: "query", description: "Filter by search query.", schema: stringSchema },
19719
19820
  { name: "page", in: "query", description: "Filter by page URL.", schema: stringSchema },
19821
+ {
19822
+ name: "orderBy",
19823
+ in: "query",
19824
+ description: "Row ordering, always descending. Defaults to clicks. Use date for time-series reads.",
19825
+ schema: { type: "string", enum: ["clicks", "impressions", "date"] }
19826
+ },
19720
19827
  limitQueryParameter,
19721
19828
  offsetQueryParameter,
19722
19829
  analyticsWindowParameter
19723
19830
  ],
19724
19831
  responses: {
19725
- // Handler returns an array of GscSearchDataDto rows (web's
19726
- // ApiGscPerformanceRow[] confirms). Was incorrectly spec'd as a
19727
- // single object, which silently truncated client types to one row.
19728
- 200: jsonArrayResponse("GSC performance rows returned.", "GscSearchDataDto"),
19832
+ // Envelope, not a bare array: `totalMatching` / `truncated` are how a
19833
+ // caller tells a page from a complete answer, and `latestAvailableDate`
19834
+ // is how it tells "no data" from "asked past the GSC reporting lag".
19835
+ 200: jsonResponse("GSC performance page plus match count and data freshness.", "GscPerformanceResponseDto"),
19836
+ 400: errorResponse("Invalid orderBy value."),
19729
19837
  404: errorResponse("Project not found.")
19730
19838
  }
19731
19839
  },
@@ -19745,6 +19853,24 @@ var routeCatalog = [
19745
19853
  404: errorResponse("Project not found.")
19746
19854
  }
19747
19855
  },
19856
+ {
19857
+ method: "get",
19858
+ path: "/api/v1/projects/{name}/google/gsc/top-pages",
19859
+ summary: "Get top GSC pages ranked by clicks",
19860
+ description: "One row per page, aggregated in SQL and ranked by summed clicks descending. The rows are a RANKING built from the dimensioned search-data table; `totals` is NOT their sum. Google withholds rare queries (summed clicks under-count) and fans one impression across every query/page/country/device combination (summed impressions over-count), so `totals` is read from the un-dimensioned property-level daily table and labelled `totalsSource: property-daily`. It is null when that table has no rows in the window.",
19861
+ tags: ["google"],
19862
+ parameters: [
19863
+ nameParameter,
19864
+ { name: "startDate", in: "query", description: "Filter by start date.", schema: stringSchema },
19865
+ { name: "endDate", in: "query", description: "Filter by end date.", schema: stringSchema },
19866
+ limitQueryParameter,
19867
+ analyticsWindowParameter
19868
+ ],
19869
+ responses: {
19870
+ 200: jsonResponse("Ranked pages plus the property-level window total.", "GscTopPagesDto"),
19871
+ 404: errorResponse("Project not found.")
19872
+ }
19873
+ },
19748
19874
  {
19749
19875
  method: "post",
19750
19876
  path: "/api/v1/projects/{name}/google/gsc/inspect",
@@ -21536,7 +21662,7 @@ var routeCatalog = [
21536
21662
  path: "/api/v1/projects/{name}/ga/traffic",
21537
21663
  summary: "Get GA4 landing page traffic, channel breakdown, and AI referral landing pages",
21538
21664
  tags: ["ga4"],
21539
- parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter],
21665
+ parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21540
21666
  responses: {
21541
21667
  // TODO: Add `GaTrafficResponse` Zod schema in contracts.
21542
21668
  200: rawJsonResponse("GA4 traffic data returned.", looseObjectSchema),
@@ -21550,7 +21676,7 @@ var routeCatalog = [
21550
21676
  summary: "Get raw AI referral detail rows per day, landing page, and attribution dimension",
21551
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.",
21552
21678
  tags: ["ga4"],
21553
- parameters: [nameParameter, analyticsWindowParameter],
21679
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21554
21680
  responses: {
21555
21681
  200: jsonArrayResponse("AI referral history returned.", "GA4AiReferralHistoryEntry"),
21556
21682
  400: errorResponse("GA4 is not connected."),
@@ -21563,7 +21689,7 @@ var routeCatalog = [
21563
21689
  summary: "Get AI referral sessions per day and per source",
21564
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.",
21565
21691
  tags: ["ga4"],
21566
- parameters: [nameParameter, analyticsWindowParameter],
21692
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21567
21693
  responses: {
21568
21694
  200: jsonResponse("AI referral daily series returned.", "GA4AiReferralDailyDto"),
21569
21695
  400: errorResponse("GA4 is not connected."),
@@ -21575,7 +21701,7 @@ var routeCatalog = [
21575
21701
  path: "/api/v1/projects/{name}/ga/social-referral-history",
21576
21702
  summary: "Get social media referral sessions per day grouped by source",
21577
21703
  tags: ["ga4"],
21578
- parameters: [nameParameter, analyticsWindowParameter],
21704
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21579
21705
  responses: {
21580
21706
  200: jsonArrayResponse("Social referral history returned.", "GA4SocialReferralHistoryEntry"),
21581
21707
  400: errorResponse("GA4 is not connected."),
@@ -21613,7 +21739,7 @@ var routeCatalog = [
21613
21739
  path: "/api/v1/projects/{name}/ga/session-history",
21614
21740
  summary: "Get total sessions per day for the project",
21615
21741
  tags: ["ga4"],
21616
- parameters: [nameParameter, analyticsWindowParameter],
21742
+ parameters: [nameParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
21617
21743
  responses: {
21618
21744
  200: jsonArrayResponse("Session history returned.", "GA4SessionHistoryEntry"),
21619
21745
  400: errorResponse("GA4 is not connected."),
@@ -24205,7 +24331,17 @@ var GA4_DIMENSIONS = {
24205
24331
  var GA4_METRICS = {
24206
24332
  sessions: "sessions",
24207
24333
  totalUsers: "totalUsers",
24208
- 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"
24209
24345
  };
24210
24346
 
24211
24347
  // ../integration-google-analytics/src/types.ts
@@ -24753,6 +24889,15 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
24753
24889
  ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
24754
24890
  return summary;
24755
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
+ }
24756
24901
  async function fetchDailyTotals(accessToken, propertyId, days) {
24757
24902
  validateAccessToken2(accessToken);
24758
24903
  validatePropertyId(propertyId);
@@ -24764,14 +24909,25 @@ async function fetchDailyTotals(accessToken, propertyId, days) {
24764
24909
  const res = await runReport(accessToken, propertyId, {
24765
24910
  dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
24766
24911
  dimensions: [{ name: GA4_DIMENSIONS.date }],
24767
- 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
+ ],
24768
24918
  limit: syncDays + 1
24769
24919
  });
24770
- const rows = (res.rows ?? []).map((row) => ({
24771
- date: compactDateToIso(row.dimensionValues[0]?.value ?? ""),
24772
- sessions: parseInt(row.metricValues[0]?.value ?? "0", 10) || 0,
24773
- users: parseInt(row.metricValues[1]?.value ?? "0", 10) || 0
24774
- })).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);
24775
24931
  ga4Log("info", "fetch-daily-totals.done", { propertyId, days: syncDays, rows: rows.length });
24776
24932
  return rows;
24777
24933
  }
@@ -25773,6 +25929,13 @@ async function googleRoutes(app, opts) {
25773
25929
  app.get("/projects/:name/google/gsc/performance", async (request) => {
25774
25930
  const project = resolveProject(app.db, request.params.name);
25775
25931
  const { startDate, endDate, query, page, limit, offset } = request.query;
25932
+ const parsedOrderBy = gscPerformanceOrderBySchema.safeParse(request.query.orderBy ?? "clicks");
25933
+ if (!parsedOrderBy.success) {
25934
+ throw validationError(
25935
+ `orderBy must be one of: ${gscPerformanceOrderBySchema.options.join(", ")}`
25936
+ );
25937
+ }
25938
+ const orderBy = parsedOrderBy.data;
25776
25939
  const cutoffDate = !startDate ? windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null : null;
25777
25940
  const conditions = [eq27(gscSearchData.projectId, project.id)];
25778
25941
  if (startDate) conditions.push(sql12`${gscSearchData.date} >= ${startDate}`);
@@ -25782,18 +25945,34 @@ async function googleRoutes(app, opts) {
25782
25945
  if (page) conditions.push(sql12`${gscSearchData.page} LIKE ${"%" + escapeLikePattern(page) + "%"} ESCAPE '\\'`);
25783
25946
  const limitVal = Math.max(parseInt(limit ?? "500", 10) || 0, 1);
25784
25947
  const offsetVal = Math.max(parseInt(offset ?? "0", 10) || 0, 0);
25785
- const rows = app.db.select().from(gscSearchData).where(and21(...conditions)).orderBy(desc13(gscSearchData.date)).limit(limitVal).offset(offsetVal).all();
25786
- return rows.map((r) => ({
25787
- date: r.date,
25788
- query: r.query,
25789
- page: r.page,
25790
- country: r.country,
25791
- device: r.device,
25792
- clicks: r.clicks,
25793
- impressions: r.impressions,
25794
- ctr: parseFloat(r.ctr),
25795
- position: parseFloat(r.position)
25796
- }));
25948
+ const orderColumns = {
25949
+ clicks: [desc13(gscSearchData.clicks), desc13(gscSearchData.date), gscSearchData.query],
25950
+ impressions: [desc13(gscSearchData.impressions), desc13(gscSearchData.date), gscSearchData.query],
25951
+ date: [desc13(gscSearchData.date), desc13(gscSearchData.clicks), gscSearchData.query]
25952
+ }[orderBy];
25953
+ const rows = app.db.select().from(gscSearchData).where(and21(...conditions)).orderBy(...orderColumns).limit(limitVal).offset(offsetVal).all();
25954
+ const totalMatching = app.db.select({ total: sql12`COUNT(*)` }).from(gscSearchData).where(and21(...conditions)).get()?.total ?? 0;
25955
+ const latestAvailableDate = app.db.select({ latest: sql12`MAX(${gscSearchData.date})` }).from(gscSearchData).where(eq27(gscSearchData.projectId, project.id)).get()?.latest ?? null;
25956
+ return {
25957
+ rows: rows.map((r) => ({
25958
+ date: r.date,
25959
+ query: r.query,
25960
+ page: r.page,
25961
+ country: r.country,
25962
+ device: r.device,
25963
+ clicks: r.clicks,
25964
+ impressions: r.impressions,
25965
+ ctr: parseFloat(r.ctr),
25966
+ position: parseFloat(r.position)
25967
+ })),
25968
+ totalMatching,
25969
+ // Account for the offset: `rows.length < totalMatching` alone reports
25970
+ // truncated=true for a page that sits past the end, where there is
25971
+ // nothing further to fetch. Truncation means "more rows follow this
25972
+ // page", so it has to measure from where this page ends.
25973
+ truncated: offsetVal + rows.length < totalMatching,
25974
+ latestAvailableDate
25975
+ };
25797
25976
  });
25798
25977
  app.get("/projects/:name/google/gsc/performance/daily", async (request) => {
25799
25978
  const project = resolveProject(app.db, request.params.name);
@@ -25837,6 +26016,56 @@ async function googleRoutes(app, opts) {
25837
26016
  daily
25838
26017
  };
25839
26018
  });
26019
+ app.get("/projects/:name/google/gsc/top-pages", async (request) => {
26020
+ const project = resolveProject(app.db, request.params.name);
26021
+ const { startDate, endDate, limit } = request.query;
26022
+ const cutoffDate = !startDate ? windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null : null;
26023
+ const conditions = [eq27(gscSearchData.projectId, project.id)];
26024
+ if (startDate) conditions.push(sql12`${gscSearchData.date} >= ${startDate}`);
26025
+ else if (cutoffDate) conditions.push(sql12`${gscSearchData.date} >= ${cutoffDate}`);
26026
+ if (endDate) conditions.push(sql12`${gscSearchData.date} <= ${endDate}`);
26027
+ const limitVal = Math.max(parseInt(limit ?? "50", 10) || 0, 1);
26028
+ const rows = app.db.select({
26029
+ page: gscSearchData.page,
26030
+ clicks: sql12`COALESCE(SUM(${gscSearchData.clicks}), 0)`,
26031
+ impressions: sql12`COALESCE(SUM(${gscSearchData.impressions}), 0)`
26032
+ }).from(gscSearchData).where(and21(...conditions)).groupBy(gscSearchData.page).orderBy(desc13(sql12`SUM(${gscSearchData.clicks})`), desc13(sql12`SUM(${gscSearchData.impressions})`)).limit(limitVal).all();
26033
+ const windowStart = startDate ?? cutoffDate ?? "";
26034
+ const windowEnd = endDate ?? "9999-12-31";
26035
+ const dailyTotals = readGscDailyTotals(app.db, project.id, windowStart, windowEnd);
26036
+ const totalClicks = dailyTotals.reduce((sum, d) => sum + d.clicks, 0);
26037
+ const totalImpressions = dailyTotals.reduce((sum, d) => sum + d.impressions, 0);
26038
+ const rankedSpan = app.db.select({
26039
+ first: sql12`MIN(${gscSearchData.date})`,
26040
+ last: sql12`MAX(${gscSearchData.date})`
26041
+ }).from(gscSearchData).where(and21(...conditions)).get();
26042
+ const coveredFrom = dailyTotals.length > 0 ? dailyTotals[0].date : null;
26043
+ const coveredThrough = dailyTotals.length > 0 ? dailyTotals[dailyTotals.length - 1].date : null;
26044
+ const totalsComplete = Boolean(
26045
+ coveredFrom && coveredThrough && rankedSpan?.first && rankedSpan?.last && coveredFrom <= rankedSpan.first && coveredThrough >= rankedSpan.last
26046
+ );
26047
+ return {
26048
+ rows: rows.map((r) => ({
26049
+ page: r.page,
26050
+ clicks: r.clicks,
26051
+ impressions: r.impressions,
26052
+ ctr: r.impressions > 0 ? r.clicks / r.impressions : 0
26053
+ })),
26054
+ totals: dailyTotals.length > 0 ? {
26055
+ clicks: totalClicks,
26056
+ impressions: totalImpressions,
26057
+ ctr: totalImpressions > 0 ? totalClicks / totalImpressions : 0,
26058
+ days: dailyTotals.length,
26059
+ coveredFrom,
26060
+ coveredThrough,
26061
+ // False when the property-level totals span less than the rows above.
26062
+ complete: totalsComplete
26063
+ } : null,
26064
+ totalsSource: "property-daily",
26065
+ rankedFrom: rankedSpan?.first ?? null,
26066
+ rankedThrough: rankedSpan?.last ?? null
26067
+ };
26068
+ });
25840
26069
  app.post("/projects/:name/google/gsc/inspect", async (request) => {
25841
26070
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
25842
26071
  if (!googleClientId || !googleClientSecret) {
@@ -32598,6 +32827,12 @@ function formatSharePct(numerator, total) {
32598
32827
  if (rounded === 0) return "<1%";
32599
32828
  return `${rounded}%`;
32600
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
+ }
32601
32836
  var SOCIAL_CHANNEL_GROUPS2 = /* @__PURE__ */ new Set(["Organic Social", "Paid Social"]);
32602
32837
  function buildChannelBreakdown(input) {
32603
32838
  const aiSessions = [...input.aiSessionsByChannelGroup.values()].reduce((sum, sessions) => sum + sessions, 0);
@@ -33054,6 +33289,12 @@ async function ga4Routes(app, opts) {
33054
33289
  date: row.date,
33055
33290
  sessions: row.sessions,
33056
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,
33057
33298
  syncedAt: now,
33058
33299
  syncRunId: runId,
33059
33300
  createdAt: now
@@ -33253,16 +33494,12 @@ async function ga4Routes(app, opts) {
33253
33494
  const project = resolveProject(app.db, request.params.name);
33254
33495
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33255
33496
  const limit = Math.max(1, Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 500));
33256
- const window = parseWindow(request.query.window);
33257
- const cutoff = windowCutoff(window);
33258
- const cutoffDate = cutoff?.slice(0, 10) ?? null;
33259
- const snapshotConditions = [eq32(gaTrafficSnapshots.projectId, project.id)];
33260
- if (cutoffDate) snapshotConditions.push(sql15`${gaTrafficSnapshots.date} >= ${cutoffDate}`);
33261
- const aiConditions = [eq32(gaAiReferrals.projectId, project.id)];
33262
- if (cutoffDate) aiConditions.push(sql15`${gaAiReferrals.date} >= ${cutoffDate}`);
33263
- const socialConditions = [eq32(gaSocialReferrals.projectId, project.id)];
33264
- if (cutoffDate) socialConditions.push(sql15`${gaSocialReferrals.date} >= ${cutoffDate}`);
33265
- 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({
33266
33503
  totalSessions: gaTrafficWindowSummaries.totalSessions,
33267
33504
  totalOrganicSessions: gaTrafficWindowSummaries.totalOrganicSessions,
33268
33505
  totalDirectSessions: gaTrafficWindowSummaries.totalDirectSessions,
@@ -33270,15 +33507,26 @@ async function ga4Routes(app, opts) {
33270
33507
  }).from(gaTrafficWindowSummaries).where(
33271
33508
  and26(
33272
33509
  eq32(gaTrafficWindowSummaries.projectId, project.id),
33273
- eq32(gaTrafficWindowSummaries.windowKey, window)
33510
+ eq32(gaTrafficWindowSummaries.windowKey, range.window)
33274
33511
  )
33275
33512
  ).get() : null;
33276
- const snapshotTotalsRow = cutoffDate && !windowSummaryRow ? app.db.select({
33277
- totalSessions: sql15`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)`,
33278
- totalOrganicSessions: sql15`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)`,
33279
- totalUsers: sql15`COALESCE(SUM(${gaTrafficSnapshots.users}), 0)`
33280
- }).from(gaTrafficSnapshots).where(and26(...snapshotConditions)).get() : null;
33281
- 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({
33282
33530
  totalSessions: gaTrafficSummaries.totalSessions,
33283
33531
  totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
33284
33532
  totalUsers: gaTrafficSummaries.totalUsers
@@ -33363,7 +33611,11 @@ async function ga4Routes(app, opts) {
33363
33611
  totalSessions: total,
33364
33612
  totalOrganicSessions,
33365
33613
  totalDirectSessions,
33366
- 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,
33367
33619
  topPages: rows.map((r) => ({
33368
33620
  landingPage: r.landingPage,
33369
33621
  sessions: r.sessions ?? 0,
@@ -33430,21 +33682,32 @@ async function ga4Routes(app, opts) {
33430
33682
  directSharePctDisplay: formatSharePct(totalDirectSessions, total),
33431
33683
  socialSharePctDisplay: formatSharePct(socialSessions, total),
33432
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.
33433
33696
  periodStart: (() => {
33434
- const start = cutoffDate ?? summaryMeta?.periodStart ?? null;
33435
- 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;
33436
33700
  if (start && end && start > end) return summaryMeta?.periodStart ?? null;
33437
33701
  return start;
33438
33702
  })(),
33439
- periodEnd: summaryMeta?.periodEnd ?? null
33703
+ periodEnd: range.endDate ?? summaryMeta?.periodEnd ?? null
33440
33704
  };
33441
33705
  });
33442
33706
  app.get("/projects/:name/ga/ai-referral-history", async (request, _reply) => {
33443
33707
  const project = resolveProject(app.db, request.params.name);
33444
33708
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33445
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33446
- const conditions = [eq32(gaAiReferrals.projectId, project.id)];
33447
- 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)];
33448
33711
  const rows = app.db.select({
33449
33712
  date: gaAiReferrals.date,
33450
33713
  source: gaAiReferrals.source,
@@ -33467,9 +33730,8 @@ async function ga4Routes(app, opts) {
33467
33730
  app.get("/projects/:name/ga/ai-referral-daily", async (request, _reply) => {
33468
33731
  const project = resolveProject(app.db, request.params.name);
33469
33732
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33470
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33471
- const conditions = [eq32(gaAiReferrals.projectId, project.id)];
33472
- 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)];
33473
33735
  const rows = app.db.select({
33474
33736
  date: gaAiReferrals.date,
33475
33737
  source: gaAiReferrals.source,
@@ -33484,9 +33746,8 @@ async function ga4Routes(app, opts) {
33484
33746
  app.get("/projects/:name/ga/social-referral-history", async (request, _reply) => {
33485
33747
  const project = resolveProject(app.db, request.params.name);
33486
33748
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33487
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33488
- const conditions = [eq32(gaSocialReferrals.projectId, project.id)];
33489
- 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)];
33490
33751
  const rows = app.db.select({
33491
33752
  date: gaSocialReferrals.date,
33492
33753
  source: gaSocialReferrals.source,
@@ -33629,17 +33890,15 @@ async function ga4Routes(app, opts) {
33629
33890
  app.get("/projects/:name/ga/session-history", async (request, _reply) => {
33630
33891
  const project = resolveProject(app.db, request.params.name);
33631
33892
  requireGa4Connection(opts, project.name, project.canonicalDomain);
33632
- const cutoffDate = windowCutoff(parseWindow(request.query.window))?.slice(0, 10) ?? null;
33633
- const conditions = [eq32(gaTrafficSnapshots.projectId, project.id)];
33634
- 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)];
33635
33895
  const rows = app.db.select({
33636
33896
  date: gaTrafficSnapshots.date,
33637
33897
  sessions: sql15`SUM(${gaTrafficSnapshots.sessions})`,
33638
33898
  organicSessions: sql15`SUM(${gaTrafficSnapshots.organicSessions})`,
33639
33899
  users: sql15`SUM(${gaTrafficSnapshots.users})`
33640
33900
  }).from(gaTrafficSnapshots).where(and26(...conditions)).groupBy(gaTrafficSnapshots.date).orderBy(gaTrafficSnapshots.date).all();
33641
- const totalConditions = [eq32(gaDailyTotals.projectId, project.id)];
33642
- if (cutoffDate) totalConditions.push(sql15`${gaDailyTotals.date} >= ${cutoffDate}`);
33901
+ const totalConditions = [eq32(gaDailyTotals.projectId, project.id), ...dateRangeConditions(gaDailyTotals.date, range)];
33643
33902
  const totals = app.db.select({ date: gaDailyTotals.date, users: gaDailyTotals.users }).from(gaDailyTotals).where(and26(...totalConditions)).all();
33644
33903
  return buildSessionHistory(
33645
33904
  rows.map((r) => ({