@ainyc/canonry 5.4.0 → 5.6.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 (41) hide show
  1. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +1 -1
  2. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +10 -0
  3. package/assets/assets/{AuditHistoryPanel-DNSskBJN.js → AuditHistoryPanel-DVUbjx1M.js} +1 -1
  4. package/assets/assets/{BacklinksPage-COtLVzT8.js → BacklinksPage-D01VTDAp.js} +1 -1
  5. package/assets/assets/ChartPrimitives-BQC2VH0O.js +1 -0
  6. package/assets/assets/{HistoryPage-D9e6cGrq.js → HistoryPage-Bk1gsXe5.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-BSs4ba4N.js → MeasurementPropertyPage-D7p5vpY_.js} +1 -1
  8. package/assets/assets/ProjectPage-Dv3USEIE.js +8 -0
  9. package/assets/assets/{RunRow-DRjXGgeI.js → RunRow-Dpa0kAip.js} +1 -1
  10. package/assets/assets/{RunsPage-BXyqLNMQ.js → RunsPage-B59z7DQ_.js} +1 -1
  11. package/assets/assets/{SettingsPage-DKmzGwx3.js → SettingsPage-HJDgmQi9.js} +1 -1
  12. package/assets/assets/{SiteHealthSection-DuSY6jzY.js → SiteHealthSection-BnJA-zvQ.js} +3 -3
  13. package/assets/assets/TrafficPage-C8na59q_.js +40 -0
  14. package/assets/assets/{TrafficSourceDetailPage-Bw8PCVl9.js → TrafficSourceDetailPage-B2fO8bDc.js} +1 -1
  15. package/assets/assets/{extract-error-message-rTGTNN_D.js → extract-error-message-670MySRM.js} +1 -1
  16. package/assets/assets/index-DTrkuKOh.js +86 -0
  17. package/assets/assets/index-PT4UiPTM.css +1 -0
  18. package/assets/assets/{react-sigma_core.esm.min-Xwhtf0bv.js → react-sigma_core.esm.min-DOhuYLQj.js} +1 -1
  19. package/assets/assets/v2-overview-adapter-CQyDNkXa.js +1 -0
  20. package/assets/assets/vendor-lucide-CneDbhOr.js +1 -0
  21. package/assets/assets/{vendor-recharts-D-cUuLZo.js → vendor-recharts-llkUFBGR.js} +1 -1
  22. package/assets/index.html +4 -4
  23. package/dist/{chunk-SLOTTXIL.js → chunk-C4KBR4H6.js} +6 -6
  24. package/dist/{chunk-Z6RA7LWY.js → chunk-D3DKDTXP.js} +1027 -70
  25. package/dist/{chunk-L3FG66O7.js → chunk-GHAUT7XJ.js} +2 -2
  26. package/dist/{chunk-67OM5KEH.js → chunk-I4AHG7K3.js} +3 -3
  27. package/dist/{chunk-P5YYRGFS.js → chunk-KZYOJ4F5.js} +2 -2
  28. package/dist/{chunk-2P52FD5B.js → chunk-TM2NQMJA.js} +803 -777
  29. package/dist/cli.js +10 -9
  30. package/dist/{demo-server-6QU77ZMV.js → demo-server-JEGVGOPQ.js} +7 -3
  31. package/dist/index.js +5 -5
  32. package/dist/{intelligence-service-PHCK6A35.js → intelligence-service-5P62KKG6.js} +2 -2
  33. package/dist/mcp.js +3 -3
  34. package/package.json +13 -13
  35. package/assets/assets/ChartPrimitives-Bxa_vFmq.js +0 -1
  36. package/assets/assets/ProjectPage-BbpGgwgd.js +0 -8
  37. package/assets/assets/TrafficPage-Bc9NGm4L.js +0 -1
  38. package/assets/assets/index-CVK43vax.js +0 -86
  39. package/assets/assets/index-Hmnj0RO5.css +0 -1
  40. package/assets/assets/v2-overview-adapter-BgGH6oLq.js +0 -1
  41. package/assets/assets/vendor-lucide-CBIL4Nkq.js +0 -1
@@ -88,8 +88,11 @@ import {
88
88
  MeasurementRunScopeError,
89
89
  PROJECTS_WRITE_SCOPE,
90
90
  READ_ONLY_SCOPE,
91
+ REPORT_HEADER_COPY,
92
+ REPORT_SECTION_COPY,
91
93
  REPORT_VISIBILITY_COPY,
92
94
  RESEARCH_RUN_SCOPE,
95
+ ReportSectionIds,
93
96
  ResearchQueryStatuses,
94
97
  ResearchRunStatuses,
95
98
  RunKinds,
@@ -127,6 +130,7 @@ import {
127
130
  USAGE_TELEMETRY_HEADERS,
128
131
  UserRoles,
129
132
  VerificationStatuses,
133
+ VisibilityReportScopeErrorReasons,
130
134
  WILDCARD_SCOPE,
131
135
  WinnabilityClasses,
132
136
  __export,
@@ -270,7 +274,6 @@ import {
270
274
  createTemplateLinkPairIndex,
271
275
  createUserRequestSchema,
272
276
  createdApiKeyDtoSchema,
273
- dedupeReportActions,
274
277
  dedupeReportOpportunities,
275
278
  deduplicateResearchQueries,
276
279
  deliveryFailed,
@@ -282,6 +285,7 @@ import {
282
285
  deriveSiteHealthState,
283
286
  deriveWinnabilityClass,
284
287
  describeError,
288
+ describeLandingPage,
285
289
  determineAnswerMentioned,
286
290
  diagnosticIdentity,
287
291
  discoveryBucketSchema,
@@ -310,16 +314,15 @@ import {
310
314
  findDuplicateLocationLabels,
311
315
  forbidden,
312
316
  formatAiReferralClassSummary,
313
- formatAverageDelta,
314
317
  formatCanonryPluginUpdateHint,
315
318
  formatCanonryPluginVersionMismatch,
316
319
  formatDate,
317
- formatDateRange,
318
320
  formatDeltaCopy,
319
321
  formatIsoDate,
320
322
  formatIsoDateInTimeZone,
321
323
  formatNumber,
322
324
  formatRatio,
325
+ formatWholePercent,
323
326
  formatWindowCountDelta,
324
327
  ga4AiReferralDailyDtoSchema,
325
328
  ga4AiReferralHistoryEntrySchema,
@@ -573,12 +576,68 @@ import {
573
576
  registrableDomain,
574
577
  relativeChangeRatio,
575
578
  reportActionCategoryLabel,
579
+ reportActionConfidenceBadge,
580
+ reportActionHorizonBadge,
576
581
  reportActionTone,
582
+ reportAudienceActions,
583
+ reportBarChartLabel,
584
+ reportCitationsTrendBaseline,
585
+ reportCitedUrlCount,
586
+ reportClientCitedSubtitle,
587
+ reportClientClicksNoun,
588
+ reportClientHeroSentence,
589
+ reportClientIndexedPages,
590
+ reportClientIndexingTone,
591
+ reportClientMentionedSubtitle,
592
+ reportClientNotIndexedTail,
593
+ reportClientProvidersSubtitle,
594
+ reportClientQueriesSubtitle,
595
+ reportClientSearchCount,
596
+ reportClientSourceCount,
597
+ reportClientTrendCopy,
598
+ reportCompactList,
577
599
  reportComparisonWindowDays,
578
- reportConfidenceLabel,
579
- reportHorizonLabel,
600
+ reportCompetitorMentionCopy,
601
+ reportCrawlerTrustSummary,
602
+ reportDeltaArrow,
603
+ reportDirectionTone,
604
+ reportExecutiveHeadline,
605
+ reportGaIntro,
606
+ reportGscIntro,
607
+ reportHeaderMarketLabel,
608
+ reportHeaderPeriodLabel,
609
+ reportIndexingIntro,
610
+ reportIndexingLegendLabel,
611
+ reportInsightTone,
612
+ reportInstanceCountLabel,
613
+ reportLineChartLabel,
614
+ reportMarketScope,
615
+ reportMissRateLabel,
616
+ reportMoreChipLabel,
617
+ reportMovementChangeCopy,
618
+ reportOpportunityActionLine,
619
+ reportPressureTone,
620
+ reportPriorWindowLabel,
621
+ reportProviderDisplayName,
622
+ reportProviderRateLabel,
580
623
  reportQueryClassLabel,
624
+ reportRateDeltaCopy,
625
+ reportReferralRedirectNote,
626
+ reportServerActivityAgencyOperatorHeaders,
627
+ reportServerActivityAgencyTiles,
628
+ reportServerActivityClientOperatorHeaders,
629
+ reportServerActivityCrawledPathsNote,
630
+ reportServerActivityHeading,
631
+ reportServerActivityOperatorDelta,
632
+ reportServerActivityPathHits,
633
+ reportServerActivityTrendTitle,
581
634
  reportSeverityLabel,
635
+ reportShareBarShareLabel,
636
+ reportSourceCategoryShareLabel,
637
+ reportSourceCategoryTone,
638
+ reportSourceOriginHeadline,
639
+ reportTrendProviderRates,
640
+ reportTruncatedList,
582
641
  reportVisibilityComparison,
583
642
  reportVisibilityEvidence,
584
643
  reportVisibilityHistoryLabel,
@@ -612,6 +671,7 @@ import {
612
671
  runTriggerRequestSchema,
613
672
  runTriggerSchema,
614
673
  runtimeStateMissing,
674
+ safeLinkHref,
615
675
  schedulableRunKindSchema,
616
676
  scheduleDtoSchema,
617
677
  scheduleExpectedUpdatedAtSchema,
@@ -709,10 +769,10 @@ import {
709
769
  wordpressSchemaDeployResultDtoSchema,
710
770
  wordpressSchemaStatusResultDtoSchema,
711
771
  wordpressStatusDtoSchema
712
- } from "./chunk-Z6RA7LWY.js";
772
+ } from "./chunk-D3DKDTXP.js";
713
773
 
714
774
  // src/intelligence-service.ts
715
- import { eq as eq68, desc as desc30, asc as asc12, and as and58, ne as ne11, or as or16, inArray as inArray26, gte as gte18, lte as lte15, isNull as isNull12, sql as sql27, exists } from "drizzle-orm";
775
+ import { eq as eq68, desc as desc30, asc as asc12, and as and58, ne as ne10, or as or17, inArray as inArray26, gte as gte18, lte as lte15, isNull as isNull12, sql as sql27, exists } from "drizzle-orm";
716
776
 
717
777
  // ../db/src/client.ts
718
778
  import { mkdirSync } from "fs";
@@ -22786,7 +22846,59 @@ async function measurementPortfolioReadRoutes(app) {
22786
22846
 
22787
22847
  // ../api-routes/src/visibility-report.ts
22788
22848
  import { createHash as createHash5 } from "crypto";
22789
- import { and as and19, desc as desc7, eq as eq24, gte as gte4, inArray as inArray9, isNotNull, isNull as isNull4, lte as lte4, ne as ne3 } from "drizzle-orm";
22849
+ import { and as and19, desc as desc7, eq as eq24, gte as gte4, inArray as inArray9, isNotNull, isNull as isNull4, lt as lt4, lte as lte4, or as or4 } from "drizzle-orm";
22850
+
22851
+ // ../api-routes/src/measurement-scope-options.ts
22852
+ function marketLinks(keys, settings) {
22853
+ if (!settings.marketLinks) return {};
22854
+ const marketKeys = [...new Set(keys ?? [])].sort();
22855
+ return marketKeys.length === 0 ? {} : { marketKeys };
22856
+ }
22857
+ function planScopeOptions(plan, settings) {
22858
+ const groupKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
22859
+ for (const group of plan.groups) {
22860
+ for (const targetKey of group.targetKeys) groupKeysForTarget.get(targetKey)?.push(group.stableKey);
22861
+ }
22862
+ const marketKeysForGroup = /* @__PURE__ */ new Map();
22863
+ const marketKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
22864
+ for (const market of plan.reportingScopes ?? []) {
22865
+ if (market.groupKey !== void 0) {
22866
+ const keys = marketKeysForGroup.get(market.groupKey) ?? [];
22867
+ keys.push(market.stableKey);
22868
+ marketKeysForGroup.set(market.groupKey, keys);
22869
+ }
22870
+ for (const edge of market.usageEdges) marketKeysForTarget.get(edge.targetKey)?.push(market.stableKey);
22871
+ }
22872
+ return [
22873
+ { id: "project", label: "Project", kind: "project", targetCount: plan.targets.length },
22874
+ ...plan.groups.map((group) => ({
22875
+ id: group.stableKey,
22876
+ label: group.label,
22877
+ kind: "group",
22878
+ targetCount: group.targetKeys.length,
22879
+ ...group.parentGroupKey === void 0 ? {} : { parentGroupIds: [group.parentGroupKey] },
22880
+ ...marketLinks(marketKeysForGroup.get(group.stableKey), settings)
22881
+ })),
22882
+ ...(plan.reportingScopes ?? []).map((market) => ({
22883
+ id: market.stableKey,
22884
+ label: market.label,
22885
+ kind: "market",
22886
+ targetCount: new Set(market.usageEdges.map((edge) => edge.targetKey)).size,
22887
+ ...market.groupKey === void 0 ? {} : { parentGroupIds: [market.groupKey] }
22888
+ })),
22889
+ ...plan.targets.map((target) => ({
22890
+ id: target.stableKey,
22891
+ label: target.label,
22892
+ kind: "property",
22893
+ targetCount: 1,
22894
+ parentGroupIds: [...new Set(groupKeysForTarget.get(target.stableKey) ?? [])].sort(),
22895
+ ...marketLinks(marketKeysForTarget.get(target.stableKey), settings)
22896
+ }))
22897
+ ];
22898
+ }
22899
+ function simpleScopeOptions() {
22900
+ return [{ id: "project", label: "Project", kind: "project", targetCount: 1 }];
22901
+ }
22790
22902
 
22791
22903
  // ../api-routes/src/visibility-report-reader.ts
22792
22904
  import { createHash as createHash4 } from "crypto";
@@ -22797,9 +22909,12 @@ var VisibilityReportCursorError = class extends Error {
22797
22909
  }
22798
22910
  };
22799
22911
  var VisibilityReportScopeError = class extends Error {
22800
- constructor(message) {
22912
+ /** Set only when the selected scope or market key is absent from the frozen definition. */
22913
+ details;
22914
+ constructor(message, details) {
22801
22915
  super(message);
22802
22916
  this.name = "VisibilityReportScopeError";
22917
+ this.details = details;
22803
22918
  }
22804
22919
  };
22805
22920
  var ALL_CLASSES = ["branded", "non-brand", "unknown"];
@@ -22840,13 +22955,13 @@ function scopeResolution(definition, selection) {
22840
22955
  const key = selection.scopeKey;
22841
22956
  if (!key) throw new VisibilityReportScopeError(`${selection.scope} scope requires a scope key.`);
22842
22957
  const scoped = definition.scopeOptions.find((candidate) => candidate.kind === selection.scope && candidate.id === key);
22843
- if (!scoped) throw new VisibilityReportScopeError(`${selection.scope} scope "${key}" is not in this frozen definition.`);
22958
+ if (!scoped) throw new VisibilityReportScopeError(`${selection.scope} scope "${key}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-scope"], kind: selection.scope, key });
22844
22959
  option = scoped;
22845
22960
  if (selection.scope === "property") {
22846
22961
  edgeIds = new Set(definition.edges.filter((edge) => edge.targetKey === key).map((edge) => edge.id));
22847
22962
  } else if (selection.scope === "group") {
22848
22963
  const group = definition.groups.find((candidate) => candidate.id === key);
22849
- if (!group) throw new VisibilityReportScopeError(`Group "${key}" is not in this frozen definition.`);
22964
+ if (!group) throw new VisibilityReportScopeError(`Group "${key}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-scope"], kind: "group", key });
22850
22965
  const targetKeys = new Set(group.targetKeys);
22851
22966
  edgeIds = new Set(definition.edges.filter((edge) => targetKeys.has(edge.targetKey)).map((edge) => edge.id));
22852
22967
  } else {
@@ -22859,7 +22974,7 @@ function scopeResolution(definition, selection) {
22859
22974
  const marketKey = selection.scope === "market" ? selection.scopeKey : selection.marketKey;
22860
22975
  if (marketKey === void 0) return { option, edgeIds };
22861
22976
  const market = definition.scopeOptions.find((candidate) => candidate.kind === "market" && candidate.id === marketKey);
22862
- if (!market) throw new VisibilityReportScopeError(`Market "${marketKey}" is not in this frozen definition.`);
22977
+ if (!market) throw new VisibilityReportScopeError(`Market "${marketKey}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-market"], kind: "market", key: marketKey });
22863
22978
  return {
22864
22979
  option,
22865
22980
  market,
@@ -22873,7 +22988,7 @@ function scopeTargetKeys(definition, selection) {
22873
22988
  if (selection.scope === "property") return [resolution.option.id];
22874
22989
  if (selection.scope === "group") {
22875
22990
  const group = definition.groups.find((candidate) => candidate.id === resolution.option.id);
22876
- if (!group) throw new VisibilityReportScopeError(`Group "${resolution.option.id}" is not in this frozen definition.`);
22991
+ if (!group) throw new VisibilityReportScopeError(`Group "${resolution.option.id}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-scope"], kind: "group", key: resolution.option.id });
22877
22992
  return [...group.targetKeys].sort(compareText14);
22878
22993
  }
22879
22994
  return [...new Set(definition.edges.filter((edge) => resolution.edgeIds.has(edge.id)).map((edge) => edge.targetKey))].sort(compareText14);
@@ -23254,17 +23369,69 @@ function filterOptions(definition, selection, run) {
23254
23369
  }
23255
23370
  return { providers, models, locations };
23256
23371
  }
23257
- function continuity(previous, current) {
23258
- if (!previous) return { state: "first", comparedRunId: null };
23372
+ function pairContinuity(previous, current) {
23259
23373
  const currentLegacy = current.definition.provenance.kind === "legacy-simple";
23260
23374
  const previousLegacy = previous.definition.provenance.kind === "legacy-simple";
23261
- if (currentLegacy || previousLegacy) return { state: "legacy-unknown", comparedRunId: previous.id };
23375
+ if (currentLegacy || previousLegacy) return "legacy-unknown";
23262
23376
  const sameDefinition = current.definitionId !== null && previous.definitionId !== null && (current.definitionId === previous.definitionId || current.comparableDefinitionIds.includes(previous.definitionId));
23263
- if (!sameDefinition) return { state: "definition-changed", comparedRunId: previous.id };
23377
+ if (!sameDefinition) return "definition-changed";
23264
23378
  if (current.modelFingerprint === null || previous.modelFingerprint === null || current.modelFingerprint !== previous.modelFingerprint) {
23265
- return { state: "model-changed", comparedRunId: previous.id };
23379
+ return "model-changed";
23266
23380
  }
23267
- return { state: "comparable", comparedRunId: previous.id };
23381
+ return "comparable";
23382
+ }
23383
+ function continuity(previous, current) {
23384
+ if (!previous) return { state: "first", comparedRunId: null };
23385
+ return { state: pairContinuity(previous, current), comparedRunId: previous.id };
23386
+ }
23387
+ function comparedRun(run) {
23388
+ return { id: run.id, createdAt: run.createdAt, completedAt: run.completedAt };
23389
+ }
23390
+ function previousSummaries(run, selection, classes) {
23391
+ const summaries = /* @__PURE__ */ new Map();
23392
+ for (const queryClass of classes) {
23393
+ try {
23394
+ const { candidates } = candidatesFor(run, selection, queryClass);
23395
+ summaries.set(queryClass, summary(candidates, run.definition, scopeTargetKeys(run.definition, selection)));
23396
+ } catch (error) {
23397
+ if (!(error instanceof VisibilityReportScopeError)) return void 0;
23398
+ summaries.set(queryClass, null);
23399
+ }
23400
+ }
23401
+ return summaries;
23402
+ }
23403
+ function comparisonBasis(selectedRun, previous, selection, classes) {
23404
+ if (previous !== null && "unreadable" in previous) return void 0;
23405
+ if (!selectedRun) return { state: "unavailable", reason: "no-selected-run", previousRun: null };
23406
+ if (selectedRun.scoped) return { state: "unavailable", reason: "scoped-run", previousRun: null };
23407
+ if (previous === null) return { state: "unavailable", reason: "no-previous-run", previousRun: null };
23408
+ if ("incomparable" in previous) return { state: "unavailable", reason: "definition-changed", previousRun: previous.incomparable };
23409
+ const pair = pairContinuity(previous.run, selectedRun);
23410
+ if (pair !== "comparable") return { state: "unavailable", reason: pair, previousRun: comparedRun(previous.run) };
23411
+ if (selectedRun.state === "partial" || previous.run.state === "partial") {
23412
+ return { state: "unavailable", reason: "partial-run", previousRun: comparedRun(previous.run) };
23413
+ }
23414
+ const summaries = previousSummaries(previous.run, selection, classes);
23415
+ return summaries === void 0 ? void 0 : { state: "available", previous: previous.run, summaries };
23416
+ }
23417
+ function rateChange(current, previous) {
23418
+ if (current.rate === null) {
23419
+ return { state: "unavailable", reason: current.reason === "not-applicable" ? "not-applicable" : "current-unavailable" };
23420
+ }
23421
+ if (previous === null || previous.rate === null) return { state: "unavailable", reason: "previous-unavailable" };
23422
+ return { state: "available", previous, delta: current.rate - previous.rate };
23423
+ }
23424
+ function populationComparison(basis, current, selection, queryClass) {
23425
+ if (basis.state === "unavailable") return { state: "unavailable", reason: basis.reason, previousRun: basis.previousRun };
23426
+ const previous = basis.summaries.get(queryClass) ?? null;
23427
+ const currentReach = selection.scope === "property" ? unavailable4("not-applicable") : current.propertyReach;
23428
+ return {
23429
+ state: "available",
23430
+ previousRun: comparedRun(basis.previous),
23431
+ mentionCoverage: rateChange(current.mentionCoverage, previous?.mentionCoverage ?? null),
23432
+ citationCoverage: rateChange(current.citationCoverage, previous?.citationCoverage ?? null),
23433
+ propertyReach: rateChange(currentReach, previous?.propertyReach ?? null)
23434
+ };
23268
23435
  }
23269
23436
  function inTime(run, selection) {
23270
23437
  return (selection.from === void 0 || run.createdAt >= selection.from) && (selection.to === void 0 || run.createdAt <= selection.to);
@@ -23305,8 +23472,12 @@ function buildVisibilityReport(input) {
23305
23472
  }
23306
23473
  runCandidates.set(run.id, byClass);
23307
23474
  }
23475
+ const basis = input.previous === void 0 ? void 0 : comparisonBasis(selectedRun, input.previous, input.selection, classes);
23308
23476
  const populations = classes.map((queryClass) => {
23309
- if (!selectedRun) return emptyPopulation(queryClass, definition, input);
23477
+ if (!selectedRun) {
23478
+ const empty = emptyPopulation(queryClass, definition, input);
23479
+ return basis === void 0 ? empty : { ...empty, comparison: populationComparison(basis, empty.summary, input.selection, queryClass) };
23480
+ }
23310
23481
  const selected = runCandidates.get(selectedRun.id)?.get(queryClass) ?? [];
23311
23482
  const trend = runs2.map((run, index2) => {
23312
23483
  const candidates = runCandidates.get(run.id)?.get(queryClass) ?? [];
@@ -23325,10 +23496,12 @@ function buildVisibilityReport(input) {
23325
23496
  });
23326
23497
  const rows = queryRows(selected, definition, input.selection);
23327
23498
  const evidence = evidenceRows(selected, definition, input.selection);
23499
+ const selectedSummary = summary(selected, definition, populationTargetKeys);
23328
23500
  return {
23329
23501
  queryClass,
23330
- summary: summary(selected, definition, populationTargetKeys),
23502
+ summary: selectedSummary,
23331
23503
  trend,
23504
+ ...basis === void 0 ? {} : { comparison: populationComparison(basis, selectedSummary, input.selection, queryClass) },
23332
23505
  queries: page2(rows, (row) => [row.queryKey, row.provider, row.model ?? "", row.location ?? ""].join("\0"), "queries", queryClass, input, selectedRun),
23333
23506
  evidence: page2(evidence, (row) => [row.answerId, row.runId].join("\0"), "evidence", queryClass, input, selectedRun),
23334
23507
  competitorAvailability: definition.competitorAvailability,
@@ -23524,56 +23697,10 @@ function v2Definition(plan, revision, manifest, run) {
23524
23697
  location: slot.context?.label ?? null
23525
23698
  };
23526
23699
  });
23527
- const groupKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
23528
- for (const group of plan.groups) {
23529
- for (const targetKey of group.targetKeys) groupKeysForTarget.get(targetKey)?.push(group.stableKey);
23530
- }
23531
- const marketKeysForGroup = /* @__PURE__ */ new Map();
23532
- const marketKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
23533
- for (const market of plan.reportingScopes ?? []) {
23534
- if (market.groupKey !== void 0) {
23535
- const keys = marketKeysForGroup.get(market.groupKey) ?? [];
23536
- keys.push(market.stableKey);
23537
- marketKeysForGroup.set(market.groupKey, keys);
23538
- }
23539
- for (const edge of market.usageEdges) marketKeysForTarget.get(edge.targetKey)?.push(market.stableKey);
23540
- }
23541
- const scopeOptions = [
23542
- { id: "project", label: "Project", kind: "project", targetCount: plan.targets.length },
23543
- ...plan.groups.map((group) => {
23544
- const marketKeys = [...new Set(marketKeysForGroup.get(group.stableKey) ?? [])].sort();
23545
- return {
23546
- id: group.stableKey,
23547
- label: group.label,
23548
- kind: "group",
23549
- targetCount: group.targetKeys.length,
23550
- ...group.parentGroupKey === void 0 ? {} : { parentGroupIds: [group.parentGroupKey] },
23551
- ...marketKeys.length === 0 ? {} : { marketKeys }
23552
- };
23553
- }),
23554
- ...plan.reportingScopes?.map((market) => ({
23555
- id: market.stableKey,
23556
- label: market.label,
23557
- kind: "market",
23558
- targetCount: new Set(market.usageEdges.map((edge) => edge.targetKey)).size,
23559
- ...market.groupKey === void 0 ? {} : { parentGroupIds: [market.groupKey] }
23560
- })) ?? [],
23561
- ...plan.targets.map((target) => {
23562
- const marketKeys = [...new Set(marketKeysForTarget.get(target.stableKey) ?? [])].sort();
23563
- return {
23564
- id: target.stableKey,
23565
- label: target.label,
23566
- kind: "property",
23567
- targetCount: 1,
23568
- parentGroupIds: [...new Set(groupKeysForTarget.get(target.stableKey) ?? [])].sort(),
23569
- ...marketKeys.length === 0 ? {} : { marketKeys }
23570
- };
23571
- })
23572
- ];
23573
23700
  return {
23574
23701
  revision,
23575
23702
  provenance: { kind: "frozen-advanced", definitionRevision: revision },
23576
- scopeOptions,
23703
+ scopeOptions: planScopeOptions(plan, { marketLinks: true }),
23577
23704
  targets: plan.targets.map((target) => ({ id: target.stableKey, label: target.label, mentionEligible: !target.mentionNotApplicable })),
23578
23705
  groups: plan.groups.map((group) => ({ id: group.stableKey, label: group.label, targetKeys: group.targetKeys })),
23579
23706
  competitorAvailability: { state: "available" },
@@ -23623,6 +23750,7 @@ function advancedRun(run, version, plan, snapshots, comparableDefinitionIds, inc
23623
23750
  completedAt: run.finishedAt,
23624
23751
  state: run.status === RunStatuses.partial ? "partial" : "measured",
23625
23752
  probe: false,
23753
+ scoped: run.measurementScope !== null,
23626
23754
  definition: v2Definition(plan, version.revision, populated.manifest, run),
23627
23755
  definitionId: version.id,
23628
23756
  comparableDefinitionIds,
@@ -23634,7 +23762,7 @@ function simpleScope(label, mentionEligible) {
23634
23762
  return {
23635
23763
  revision: null,
23636
23764
  provenance: { kind: "legacy-simple", definitionRevision: null },
23637
- scopeOptions: [{ id: "project", label: "Project", kind: "project", targetCount: 1 }],
23765
+ scopeOptions: simpleScopeOptions(),
23638
23766
  targets: [{ id: "project", label, mentionEligible }],
23639
23767
  groups: [],
23640
23768
  competitorAvailability: { state: "unavailable", reason: "frozen-competitor-identity-missing" }
@@ -23734,6 +23862,7 @@ function frozenSimpleRun(run, definition, snapshots, includeEvidence = true) {
23734
23862
  completedAt: run.finishedAt,
23735
23863
  state: run.status === RunStatuses.partial ? "partial" : "measured",
23736
23864
  probe: false,
23865
+ scoped: run.measurementScope !== null,
23737
23866
  definition: {
23738
23867
  ...simpleScope(definition.identity.displayName, matcher.keys.size > 0),
23739
23868
  provenance: { kind: "frozen-simple", definitionRevision: null },
@@ -23772,6 +23901,7 @@ function legacySimpleRun(project, run, snapshots, includeEvidence = true) {
23772
23901
  completedAt: run.finishedAt,
23773
23902
  state: run.status === RunStatuses.partial ? "partial" : "measured",
23774
23903
  probe: false,
23904
+ scoped: run.measurementScope !== null,
23775
23905
  definition: {
23776
23906
  ...simpleScope(project.displayName, true),
23777
23907
  slots,
@@ -23874,7 +24004,7 @@ function completedVisibilityRuns(db, projectId, planless, query) {
23874
24004
  eq24(runs.projectId, projectId),
23875
24005
  eq24(runs.kind, RunKinds["answer-visibility"]),
23876
24006
  inArray9(runs.status, [RunStatuses.completed, RunStatuses.partial]),
23877
- ne3(runs.trigger, RunTriggers.probe),
24007
+ notProbeRun(),
23878
24008
  planless ? isNull4(runs.measurementPlanVersionId) : isNotNull(runs.measurementPlanVersionId),
23879
24009
  // A scoped spot check is never a whole-project sweep. It can be inspected
23880
24010
  // by its exact id, but must not win the default latest-result selection.
@@ -23884,6 +24014,27 @@ function completedVisibilityRuns(db, projectId, planless, query) {
23884
24014
  query.to === void 0 ? void 0 : lte4(runs.createdAt, query.to)
23885
24015
  )).orderBy(desc7(runs.createdAt), desc7(runs.id)).limit(VISIBILITY_REPORT_MAX_RUNS).all();
23886
24016
  }
24017
+ function previousEligibleVisibilityRun(db, projectId, planless, selected) {
24018
+ return db.select().from(runs).where(and19(
24019
+ eq24(runs.projectId, projectId),
24020
+ eq24(runs.kind, RunKinds["answer-visibility"]),
24021
+ inArray9(runs.status, [RunStatuses.completed, RunStatuses.partial]),
24022
+ notProbeRun(),
24023
+ planless ? isNull4(runs.measurementPlanVersionId) : isNotNull(runs.measurementPlanVersionId),
24024
+ isNull4(runs.measurementScope),
24025
+ or4(
24026
+ lt4(runs.createdAt, selected.createdAt),
24027
+ and19(eq24(runs.createdAt, selected.createdAt), lt4(runs.id, selected.id))
24028
+ )
24029
+ )).orderBy(desc7(runs.createdAt), desc7(runs.id)).limit(1).get();
24030
+ }
24031
+ function readPreviousSweep(read) {
24032
+ try {
24033
+ return read();
24034
+ } catch {
24035
+ return { unreadable: true };
24036
+ }
24037
+ }
23887
24038
  function parseV2Versions(rows) {
23888
24039
  const versions = /* @__PURE__ */ new Map();
23889
24040
  for (const row of rows) {
@@ -23924,7 +24075,7 @@ function pendingAssignments(activePlan, measuredPlan, measuredDefinition) {
23924
24075
  const measuredSignatures = new Set(measuredPlan.assignments.filter((assignment) => measuredEdges.has([assignment.executionNodeKey, assignment.targetKey, assignment.queryId].join("\0"))).map((assignment) => assignmentSignature(measuredPlan, assignment)));
23925
24076
  return activePlan.assignments.filter((assignment) => !measuredSignatures.has(assignmentSignature(activePlan, assignment))).length;
23926
24077
  }
23927
- function advancedReaderInput(db, projectId, active, query) {
24078
+ function advancedReaderInput(db, projectId, active, query, includeComparison) {
23928
24079
  if (active.plan.schemaVersion !== MEASUREMENT_PLAN_V2_SCHEMA_VERSION) throw new Error("Expected a v2 active plan");
23929
24080
  const allVersionRows = db.select().from(measurementPlanVersions).where(eq24(measurementPlanVersions.projectId, projectId)).all();
23930
24081
  const allVersionRowsById = new Map(allVersionRows.map((row) => [row.id, row]));
@@ -23954,7 +24105,7 @@ function advancedReaderInput(db, projectId, active, query) {
23954
24105
  const compatibleSourceCandidates = sourceCandidates.filter((candidate) => activeComparableIds.has(candidate.source.row.id));
23955
24106
  const preferredSource = query.runId === void 0 ? compatibleSourceCandidates.at(0) ?? sourceCandidates.at(0) : void 0;
23956
24107
  const selectedSource = query.runId === void 0 ? preferredSource : sourceCandidates.find((candidate) => candidate.run.id === query.runId);
23957
- const candidates = sourceCandidates.map((candidate) => {
24108
+ const materialize = (candidate, includeEvidence) => {
23958
24109
  const usePresentation = presentationComparableIds.has(candidate.source.row.id);
23959
24110
  const version = usePresentation ? presentationVersion : candidate.source.row;
23960
24111
  const plan = usePresentation ? presentationPlan : candidate.source.plan;
@@ -23965,10 +24116,25 @@ function advancedReaderInput(db, projectId, active, query) {
23965
24116
  plan,
23966
24117
  snapshots,
23967
24118
  usePresentation ? [...presentationComparableIds] : comparableVersionIds(allVersionRowsById, candidate.source.row.id),
23968
- candidate.run.id === selectedSource?.run.id
24119
+ includeEvidence
23969
24120
  );
23970
- });
24121
+ };
24122
+ const candidates = sourceCandidates.map((candidate) => materialize(candidate, candidate.run.id === selectedSource?.run.id));
23971
24123
  const selectedDefinition = candidates.find((candidate) => candidate.id === selectedSource?.run.id)?.definition;
24124
+ const previousInput = () => {
24125
+ if (selectedSource === void 0 || selectedSource.run.measurementScope !== null) return null;
24126
+ return readPreviousSweep(() => {
24127
+ const predecessor = previousEligibleVisibilityRun(db, projectId, false, selectedSource.run);
24128
+ if (predecessor === void 0) return null;
24129
+ const loaded = candidates.find((candidate) => candidate.id === predecessor.id);
24130
+ if (loaded !== void 0) return { run: loaded };
24131
+ const source = predecessor.measurementPlanVersionId === null ? void 0 : versions.get(predecessor.measurementPlanVersionId);
24132
+ if (source === void 0) {
24133
+ return { incomparable: { id: predecessor.id, createdAt: predecessor.createdAt, completedAt: predecessor.finishedAt } };
24134
+ }
24135
+ return { run: materialize({ run: predecessor, source }, false) };
24136
+ });
24137
+ };
23972
24138
  return {
23973
24139
  mode: "advanced",
23974
24140
  activeRevision: active.version.revision,
@@ -23976,35 +24142,52 @@ function advancedReaderInput(db, projectId, active, query) {
23976
24142
  selection: query,
23977
24143
  activeDefinition: activeV2Definition(presentationPlan, presentationVersion.revision),
23978
24144
  ...preferredSource === void 0 ? {} : { preferredRunId: preferredSource.run.id },
23979
- runs: candidates
24145
+ runs: candidates,
24146
+ ...includeComparison ? { previous: previousInput() } : {}
23980
24147
  };
23981
24148
  }
23982
- function simpleReaderInput(db, project, query) {
23983
- const sourceRuns = completedVisibilityRuns(db, project.id, true, query);
23984
- const frozen = new Map(sourceRuns.length === 0 ? [] : db.select().from(simpleMeasurementDefinitions).where(and19(
23985
- eq24(simpleMeasurementDefinitions.projectId, project.id),
23986
- inArray9(simpleMeasurementDefinitions.runId, sourceRuns.map((run) => run.id))
24149
+ function frozenSimpleDefinitions(db, projectId, runIds) {
24150
+ if (runIds.length === 0) return /* @__PURE__ */ new Map();
24151
+ return new Map(db.select().from(simpleMeasurementDefinitions).where(and19(
24152
+ eq24(simpleMeasurementDefinitions.projectId, projectId),
24153
+ inArray9(simpleMeasurementDefinitions.runId, [...runIds])
23987
24154
  )).all().map((row) => [row.runId, row.definition]));
23988
- const selectedRunId = sourceRuns[0]?.id;
23989
- const candidates = sourceRuns.map((run) => {
23990
- const snapshots = loadVisibilitySnapshots(db, [run.id]).get(run.id) ?? [];
23991
- const definition = frozen.get(run.id);
23992
- const includeEvidence = run.id === selectedRunId;
23993
- return definition ? frozenSimpleRun(run, definition, snapshots, includeEvidence) : legacySimpleRun(project, run, snapshots, includeEvidence);
23994
- });
24155
+ }
24156
+ function simpleRunInput(db, project, run, definition, includeEvidence) {
24157
+ const snapshots = loadVisibilitySnapshots(db, [run.id]).get(run.id) ?? [];
24158
+ return definition ? frozenSimpleRun(run, definition, snapshots, includeEvidence) : legacySimpleRun(project, run, snapshots, includeEvidence);
24159
+ }
24160
+ function simpleReaderInput(db, project, query, includeComparison) {
24161
+ const sourceRuns = completedVisibilityRuns(db, project.id, true, query);
24162
+ const frozen = frozenSimpleDefinitions(db, project.id, sourceRuns.map((run) => run.id));
24163
+ const selectedRun = sourceRuns.at(0);
24164
+ const candidates = sourceRuns.map((run) => simpleRunInput(db, project, run, frozen.get(run.id), run.id === selectedRun?.id));
23995
24165
  if (query.runId !== void 0 && !candidates.some((run) => run.id === query.runId)) {
23996
24166
  throw validationError(`Measurement run "${query.runId}" is not an eligible simple result.`);
23997
24167
  }
24168
+ const previousInput = () => {
24169
+ if (selectedRun === void 0 || selectedRun.measurementScope !== null) return null;
24170
+ return readPreviousSweep(() => {
24171
+ const predecessor = previousEligibleVisibilityRun(db, project.id, true, selectedRun);
24172
+ if (predecessor === void 0) return null;
24173
+ const loaded = candidates.find((candidate) => candidate.id === predecessor.id);
24174
+ if (loaded !== void 0) return { run: loaded };
24175
+ const definition = frozenSimpleDefinitions(db, project.id, [predecessor.id]).get(predecessor.id);
24176
+ return { run: simpleRunInput(db, project, predecessor, definition, false) };
24177
+ });
24178
+ };
23998
24179
  return {
23999
24180
  mode: "simple",
24000
24181
  activeRevision: null,
24001
24182
  pendingAssignmentCount: 0,
24002
24183
  selection: query,
24003
24184
  activeDefinition: simpleActiveDefinition(project),
24004
- runs: candidates
24185
+ runs: candidates,
24186
+ ...includeComparison ? { previous: previousInput() } : {}
24005
24187
  };
24006
24188
  }
24007
- function readVisibilityReport(db, project, rawQuery) {
24189
+ function readVisibilityReport(db, project, rawQuery, options = {}) {
24190
+ const includeComparison = options.includeComparison ?? true;
24008
24191
  const query = parseQuery(rawQuery);
24009
24192
  const active = activeMeasurementPlan(db, project.id);
24010
24193
  const mode = query.mode === "auto" ? active === null ? "simple" : "advanced" : query.mode;
@@ -24012,13 +24195,12 @@ function readVisibilityReport(db, project, rawQuery) {
24012
24195
  if (mode === "advanced") {
24013
24196
  if (active === null) throw validationError("This project has no advanced measurement plan.");
24014
24197
  if (active.plan.schemaVersion !== MEASUREMENT_PLAN_V2_SCHEMA_VERSION) return unsupportedAdvancedResponse(query, active);
24015
- return buildVisibilityReport(advancedReaderInput(db, project.id, active, query));
24198
+ return buildVisibilityReport(advancedReaderInput(db, project.id, active, query, includeComparison));
24016
24199
  }
24017
- return buildVisibilityReport(simpleReaderInput(db, project, query));
24200
+ return buildVisibilityReport(simpleReaderInput(db, project, query, includeComparison));
24018
24201
  } catch (error) {
24019
- if (error instanceof VisibilityReportCursorError || error instanceof VisibilityReportScopeError) {
24020
- throw validationError(error.message);
24021
- }
24202
+ if (error instanceof VisibilityReportScopeError) throw validationError(error.message, error.details);
24203
+ if (error instanceof VisibilityReportCursorError) throw validationError(error.message);
24022
24204
  throw error;
24023
24205
  }
24024
24206
  }
@@ -24031,7 +24213,7 @@ async function visibilityReportRoutes(app) {
24031
24213
 
24032
24214
  // ../api-routes/src/query-tracking.ts
24033
24215
  import crypto20 from "crypto";
24034
- import { and as and20, desc as desc8, eq as eq25, inArray as inArray10, isNotNull as isNotNull2, isNull as isNull5, ne as ne4 } from "drizzle-orm";
24216
+ import { and as and20, desc as desc8, eq as eq25, inArray as inArray10, isNotNull as isNotNull2, isNull as isNull5, ne as ne3 } from "drizzle-orm";
24035
24217
  var SOURCE_LIMIT = 100;
24036
24218
  var MAX_REVIEW_AGE_MS = 15 * 60 * 1e3;
24037
24219
  var MAX_REVIEW_FUTURE_SKEW_MS = 60 * 1e3;
@@ -24214,7 +24396,7 @@ function simpleMeasuredAtByQuery(db, project, queryRows2, opts) {
24214
24396
  eq25(runs.projectId, project.id),
24215
24397
  eq25(runs.kind, RunKinds["answer-visibility"]),
24216
24398
  inArray10(runs.status, [RunStatuses.completed, RunStatuses.partial]),
24217
- ne4(runs.trigger, RunTriggers.probe),
24399
+ ne3(runs.trigger, RunTriggers.probe),
24218
24400
  isNull5(runs.measurementScope),
24219
24401
  isNull5(runs.measurementPlanVersionId)
24220
24402
  )).orderBy(desc8(runs.createdAt), desc8(runs.id)).limit(SOURCE_LIMIT).all();
@@ -24336,7 +24518,7 @@ function measuredAtByQuery(db, state, opts) {
24336
24518
  eq25(runs.projectId, project.id),
24337
24519
  eq25(runs.kind, RunKinds["answer-visibility"]),
24338
24520
  inArray10(runs.status, [RunStatuses.completed, RunStatuses.partial]),
24339
- ne4(runs.trigger, RunTriggers.probe),
24521
+ ne3(runs.trigger, RunTriggers.probe),
24340
24522
  isNull5(runs.measurementScope),
24341
24523
  isNotNull2(runs.measurementPlanVersionId)
24342
24524
  )).orderBy(desc8(runs.createdAt), desc8(runs.id)).limit(SOURCE_LIMIT).all();
@@ -24553,6 +24735,8 @@ function workspaceDto(db, state, opts) {
24553
24735
  ...group.parentGroupKey ? { parentGroupKey: group.parentGroupKey } : {}
24554
24736
  })) ?? [],
24555
24737
  markets: plan?.reportingScopes?.map((scope) => ({ stableKey: scope.stableKey, label: scope.label, usageEdges: scope.usageEdges, ...scope.groupKey ? { groupKey: scope.groupKey } : {} })) ?? [],
24738
+ // Tracked assignments have no market intersection, so Groups and Properties carry no market links.
24739
+ scopeOptions: plan ? planScopeOptions(plan, { marketLinks: false }) : simpleScopeOptions(),
24556
24740
  tracked: trackedRows(db, state, rows, plan, opts),
24557
24741
  savedSources: savedSources(db, state.project.id)
24558
24742
  });
@@ -26184,7 +26368,7 @@ function normalizeCompetitorList2(domains) {
26184
26368
  }
26185
26369
 
26186
26370
  // ../api-routes/src/history.ts
26187
- import { and as and22, asc as asc3, count, desc as desc9, eq as eq27, gte as gte5, inArray as inArray11, like, ne as ne5, or as or4 } from "drizzle-orm";
26371
+ import { and as and22, asc as asc3, count, desc as desc9, eq as eq27, gte as gte5, inArray as inArray11, like, ne as ne4, or as or5 } from "drizzle-orm";
26188
26372
 
26189
26373
  // ../api-routes/src/notification-redaction.ts
26190
26374
  var REDACTED_URL = {
@@ -26263,7 +26447,7 @@ async function historyRoutes(app) {
26263
26447
  app.get("/projects/:name/history", async (request, reply) => {
26264
26448
  const project = resolveProject(app.db, request.params.name);
26265
26449
  const filters = [eq27(auditLog.projectId, project.id)];
26266
- if (request.operatorAccess !== true) filters.push(ne5(auditLog.entityType, "telemetry"));
26450
+ if (request.operatorAccess !== true) filters.push(ne4(auditLog.entityType, "telemetry"));
26267
26451
  addAuditHistoryFilters(filters, request.query);
26268
26452
  const rows = app.db.select().from(auditLog).where(and22(...filters)).orderBy(desc9(auditLog.createdAt), desc9(auditLog.id)).limit(parseBoundedInt(request.query.limit, 100, 500)).offset(parseBoundedInt(request.query.offset, 0, Number.MAX_SAFE_INTEGER)).all();
26269
26453
  return reply.send(rows.map(formatAuditEntry));
@@ -26271,7 +26455,7 @@ async function historyRoutes(app) {
26271
26455
  app.get("/history", async (request, reply) => {
26272
26456
  const scopedProjectId = request.apiKey?.projectId;
26273
26457
  const filters = scopedProjectId ? [eq27(auditLog.projectId, scopedProjectId)] : [];
26274
- if (request.operatorAccess !== true) filters.push(ne5(auditLog.entityType, "telemetry"));
26458
+ if (request.operatorAccess !== true) filters.push(ne4(auditLog.entityType, "telemetry"));
26275
26459
  addAuditHistoryFilters(filters, request.query);
26276
26460
  const rows = app.db.select().from(auditLog).where(filters.length > 0 ? and22(...filters) : void 0).orderBy(desc9(auditLog.createdAt), desc9(auditLog.id)).limit(parseBoundedInt(request.query.limit, 100, 500)).offset(parseBoundedInt(request.query.offset, 0, Number.MAX_SAFE_INTEGER)).all();
26277
26461
  return reply.send(rows.map(formatAuditEntry));
@@ -26572,7 +26756,7 @@ function formatAuditEntry(row) {
26572
26756
  function addAuditHistoryFilters(filters, query) {
26573
26757
  if (query.since && !Number.isNaN(Date.parse(query.since))) filters.push(gte5(auditLog.createdAt, query.since));
26574
26758
  if (query.action) filters.push(eq27(auditLog.action, query.action));
26575
- if (query.actor === "api") filters.push(or4(
26759
+ if (query.actor === "api") filters.push(or5(
26576
26760
  eq27(auditLog.actor, "api"),
26577
26761
  like(auditLog.actor, "api-key:%"),
26578
26762
  like(auditLog.actor, "user:%")
@@ -26593,7 +26777,7 @@ function parseOptionalPositiveInt(value, max2) {
26593
26777
  }
26594
26778
 
26595
26779
  // ../api-routes/src/analytics.ts
26596
- import { and as and23, desc as desc10, eq as eq28, gte as gte6, inArray as inArray12, lt as lt4 } from "drizzle-orm";
26780
+ import { and as and23, desc as desc10, eq as eq28, gte as gte6, inArray as inArray12, lt as lt5 } from "drizzle-orm";
26597
26781
 
26598
26782
  // ../api-routes/src/analytics-model-attribution.ts
26599
26783
  function logicalSweeps(observations) {
@@ -26785,7 +26969,7 @@ async function analyticsRoutes(app) {
26785
26969
  eq28(runs.kind, RunKinds["answer-visibility"]),
26786
26970
  inArray12(runs.status, [RunStatuses.completed, RunStatuses.partial]),
26787
26971
  notProbeRun(),
26788
- lt4(runs.createdAt, cutoff)
26972
+ lt5(runs.createdAt, cutoff)
26789
26973
  );
26790
26974
  const anchorSweepTimes = app.db.selectDistinct({ createdAt: runs.createdAt }).from(runs).where(anchorRunPredicate).orderBy(desc10(runs.createdAt)).limit(ANCHOR_SWEEP_SCAN_LIMIT + 1).all().map((row) => row.createdAt);
26791
26975
  const anchorScanTruncated = anchorSweepTimes.length > ANCHOR_SWEEP_SCAN_LIMIT;
@@ -27619,7 +27803,7 @@ function nonSubresourceReferralPathCondition() {
27619
27803
  }
27620
27804
 
27621
27805
  // ../api-routes/src/report.ts
27622
- import { and as and29, desc as desc14, eq as eq33, gte as gte8, inArray as inArray15, lt as lt5, lte as lte6, ne as ne6, or as or5, sql as sql11 } from "drizzle-orm";
27806
+ import { and as and29, desc as desc14, eq as eq33, gte as gte8, inArray as inArray15, lt as lt6, lte as lte6, ne as ne5, or as or6, sql as sql11 } from "drizzle-orm";
27623
27807
 
27624
27808
  // ../api-routes/src/content.ts
27625
27809
  import crypto23 from "crypto";
@@ -28590,141 +28774,22 @@ function escapeHtml2(value) {
28590
28774
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
28591
28775
  }
28592
28776
  function safeHref(value) {
28593
- const trimmed = (value ?? "").trim();
28594
- if (!trimmed) return "#";
28595
- if (trimmed.startsWith("/")) return escapeHtml2(trimmed);
28596
- if (/^https?:\/\//i.test(trimmed)) return escapeHtml2(trimmed);
28597
- if (/^mailto:/i.test(trimmed)) return escapeHtml2(trimmed);
28598
- return "#";
28599
- }
28600
- function summarizeQueryParams(params) {
28601
- const keys = Array.from(params.keys());
28602
- const total = keys.length;
28603
- if (total === 0) return "";
28604
- const noun = total === 1 ? "param" : "params";
28605
- const tag = inferAdSource(params);
28606
- return tag ? `${tag} \xB7 ${total} ${noun}` : `${total} tracking ${noun}`;
28607
- }
28608
- function inferAdSource(params) {
28609
- if (params.has("fbclid")) return "Facebook Ad";
28610
- if (params.has("gclid") || params.has("gbraid") || params.has("wbraid")) return "Google Ad";
28611
- if (params.has("msclkid")) return "Microsoft Ad";
28612
- if (params.has("ttclid")) return "TikTok Ad";
28613
- if (params.has("li_fat_id")) return "LinkedIn Ad";
28614
- if (params.has("twclid")) return "X / Twitter Ad";
28615
- if (params.has("epik")) return "Pinterest Ad";
28616
- for (const k of params.keys()) {
28617
- if (k.startsWith("hsa_")) return "Search Ad";
28618
- }
28619
- const src = params.get("utm_source");
28620
- const med = params.get("utm_medium");
28621
- if (src && med) return `${src} / ${med}`;
28622
- if (src) return `Source: ${src}`;
28623
- if (med) return `Medium: ${med}`;
28624
- return null;
28777
+ return escapeHtml2(safeLinkHref(value));
28625
28778
  }
28626
28779
  function formatLandingPageHtml(raw) {
28627
- const value = raw ?? "";
28628
- const queryIdx = value.indexOf("?");
28629
- const path8 = queryIdx === -1 ? value : value.slice(0, queryIdx);
28630
- const query = queryIdx === -1 ? "" : value.slice(queryIdx + 1);
28631
- const pathHtml = `<span class="page-path">${escapeHtml2(path8 || "/")}</span>`;
28632
- if (!query) return pathHtml;
28633
- let summary2 = "";
28634
- try {
28635
- summary2 = summarizeQueryParams(new URLSearchParams(query));
28636
- } catch {
28637
- summary2 = "tracking params";
28638
- }
28639
- if (!summary2) return pathHtml;
28640
- return `${pathHtml}<span class="page-query" title="${escapeHtml2(value)}">${escapeHtml2(summary2)}</span>`;
28641
- }
28642
- function gscDateRange(report) {
28643
- const summary2 = report.executiveSummary.gsc;
28644
- const gsc = report.gsc;
28645
- const start = summary2?.periodStart || gsc?.periodStart || gsc?.trend[0]?.date || "";
28646
- const end = summary2?.periodEnd || gsc?.periodEnd || gsc?.trend.at(-1)?.date || "";
28647
- return formatDateRange(start, end);
28648
- }
28649
- function pluralize(count4, singular, plural = `${singular}s`) {
28650
- return count4 === 1 ? singular : plural;
28651
- }
28652
- var PROVIDER_DISPLAY_NAMES = {
28653
- gemini: "Gemini",
28654
- openai: "ChatGPT",
28655
- claude: "Claude",
28656
- perplexity: "Perplexity",
28657
- local: "Local model",
28658
- "cdp:chatgpt": "ChatGPT (browser)"
28659
- };
28660
- function providerDisplayName(name) {
28661
- return PROVIDER_DISPLAY_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1);
28662
- }
28663
- function clientHorizonLabel(horizon) {
28664
- switch (horizon) {
28665
- case "immediate":
28666
- return "Do now";
28667
- case "short-term":
28668
- return "This month";
28669
- case "medium-term":
28670
- return "Next quarter";
28671
- }
28672
- }
28673
- function clientConfidenceLabel(confidence) {
28674
- switch (confidence) {
28675
- case "high":
28676
- return "Strong evidence";
28677
- case "medium":
28678
- return "Some evidence";
28679
- case "low":
28680
- return "Worth trying";
28681
- }
28682
- }
28683
- function clientTrendCopy(delta) {
28684
- if (!delta) return null;
28685
- if (delta.direction === "up") {
28686
- return { text: `Up ${delta.deltaAbs.toFixed(1)} points ${compareCopy(delta)}`, tone: "positive", arrow: "\u2191" };
28687
- }
28688
- if (delta.direction === "down") {
28689
- return { text: `Down ${Math.abs(delta.deltaAbs).toFixed(1)} points ${compareCopy(delta)}`, tone: "negative", arrow: "\u2193" };
28690
- }
28691
- return { text: `Holding steady ${compareCopy(delta)}`, tone: "neutral", arrow: "\u2192" };
28692
- }
28693
- function compareCopy(delta) {
28694
- const window = delta.window ?? 1;
28695
- return window >= 2 ? `vs prior ${window} checks (avg ${delta.prior}%)` : `since last check (was ${delta.prior}%)`;
28696
- }
28697
- function compactInlineList(items, limit = 3) {
28698
- const visible = items.slice(0, limit);
28699
- const more = items.length - visible.length;
28700
- return `${visible.join(", ")}${more > 0 ? `, +${more} more` : ""}`;
28780
+ const page3 = describeLandingPage(raw);
28781
+ const pathHtml = `<span class="page-path">${escapeHtml2(page3.path)}</span>`;
28782
+ if (!page3.querySummary) return pathHtml;
28783
+ return `${pathHtml}<span class="page-query" title="${escapeHtml2(page3.raw)}">${escapeHtml2(page3.querySummary)}</span>`;
28701
28784
  }
28702
28785
  function renderProofChips(items, limit = 3) {
28703
28786
  if (items.length === 0) return "";
28704
28787
  const visible = items.slice(0, limit);
28705
28788
  const more = items.length - visible.length;
28706
28789
  const chips = visible.map((item2) => `<span class="proof-chip">${escapeHtml2(item2)}</span>`);
28707
- if (more > 0) chips.push(`<span class="proof-chip">+${more} more</span>`);
28790
+ if (more > 0) chips.push(`<span class="proof-chip">${reportMoreChipLabel(more)}</span>`);
28708
28791
  return `<div class="proof-chips">${chips.join("")}</div>`;
28709
28792
  }
28710
- function pressureTone2(label) {
28711
- if (label === "High") return "negative";
28712
- if (label === "Moderate") return "caution";
28713
- if (label === "Low") return "positive";
28714
- return "neutral";
28715
- }
28716
- function severityTone(severity) {
28717
- switch (severity) {
28718
- case "critical":
28719
- return "negative";
28720
- case "high":
28721
- return "negative";
28722
- case "medium":
28723
- return "caution";
28724
- case "low":
28725
- return "neutral";
28726
- }
28727
- }
28728
28793
  var STYLE = `
28729
28794
  :root {
28730
28795
  color-scheme: dark;
@@ -28942,6 +29007,7 @@ section.report-section .section-intro {
28942
29007
  .source-bar-pct { color: ${COLORS.textFaint}; font-size: 11px; }
28943
29008
  .driver-list { margin: 0; padding-left: 16px; font-size: 12px; color: ${COLORS.textMuted}; }
28944
29009
  .driver-list li { margin: 2px 0; }
29010
+ .table-scroll { overflow-x: auto; }
28945
29011
  table.report-table {
28946
29012
  width: 100%;
28947
29013
  border-collapse: collapse;
@@ -28964,7 +29030,7 @@ table.report-table th {
28964
29030
  }
28965
29031
  table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
28966
29032
  table.report-table td.page-cell { max-width: 0; }
28967
- table.insights-table { table-layout: fixed; }
29033
+ table.insights-table { table-layout: fixed; min-width: 680px; }
28968
29034
  table.insights-table th.col-severity, table.insights-table td.col-severity { width: 96px; }
28969
29035
  table.insights-table th.col-query, table.insights-table td.col-query { width: 18%; }
28970
29036
  table.insights-table th.col-provider, table.insights-table td.col-provider { width: 88px; }
@@ -28995,6 +29061,7 @@ table.report-table td .badge {
28995
29061
  font-weight: 600;
28996
29062
  border: 1px solid;
28997
29063
  }
29064
+ table.report-table td p.muted { margin: 2px 0 0; font-size: 12px; color: ${COLORS.textMuted}; }
28998
29065
  .cell-cited { color: ${COLORS.positive}; font-weight: 600; }
28999
29066
  .cell-not-cited { color: ${COLORS.textFaint}; }
29000
29067
  .cell-pending { color: ${COLORS.textFaint}; font-style: italic; }
@@ -29020,7 +29087,7 @@ table.report-table td .badge {
29020
29087
  }
29021
29088
  .chart-grid {
29022
29089
  display: grid;
29023
- grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
29090
+ grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
29024
29091
  gap: 16px;
29025
29092
  }
29026
29093
  .mention-branded-block {
@@ -29374,7 +29441,7 @@ table.report-table td .badge {
29374
29441
  .client-progress-fill.tone-negative { background: ${COLORS.negative}b3; }
29375
29442
  .client-evidence-grid {
29376
29443
  display: grid;
29377
- grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
29444
+ grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
29378
29445
  gap: 16px;
29379
29446
  }
29380
29447
  .client-opportunity-list {
@@ -29468,6 +29535,7 @@ table.report-table td .badge {
29468
29535
  print-color-adjust: exact;
29469
29536
  }
29470
29537
  .container { max-width: none; padding: 0; }
29538
+ .table-scroll { overflow: visible; }
29471
29539
  section.report-section,
29472
29540
  .executive-hero,
29473
29541
  .headline-card,
@@ -29499,46 +29567,34 @@ function section(opts, body) {
29499
29567
  function renderEmpty(message) {
29500
29568
  return `<div class="empty-state">${escapeHtml2(message)}</div>`;
29501
29569
  }
29502
- function locationDisplay(location) {
29503
- if (!location) return "";
29504
- const place = [location.city, location.region, location.country].filter(Boolean).join(", ");
29505
- return place ? `${location.label} (${place})` : location.label;
29506
- }
29507
29570
  function renderHeaderLocationFragment(location) {
29508
- if (!location) return " \xB7 No market set";
29509
- return ` \xB7 Market: ${escapeHtml2(locationDisplay(location))}`;
29571
+ return ` \xB7 ${escapeHtml2(reportHeaderMarketLabel(location))}`;
29510
29572
  }
29511
29573
  function renderLocationCard(report) {
29512
- const location = report.meta.location;
29513
- const handling = report.meta.providerLocationHandling;
29514
- if (!location && handling.length === 0) return "";
29515
- const otherLocations = location?.otherConfiguredLabels ?? [];
29516
- const weakLocationProviders = handling.filter((h) => h.treatment === "ignored" || h.treatment === "browser-geo").map((h) => h.provider);
29517
- const marketValue = location ? locationDisplay(location) : "No market set";
29518
- const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : "None";
29519
- const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching check before cross-market recommendations.` : "Single-market report; findings can be read as the current market view." : "No geographic hint was attached to this check; read findings as default-market or national results.";
29520
- const providerCopy = handling.length > 0 ? weakLocationProviders.length > 0 ? `${weakLocationProviders.length} ${pluralize(weakLocationProviders.length, "provider")} need a closer location check.` : `${handling.length} ${pluralize(handling.length, "provider")} received the market context.` : "No provider-level location metadata is available for this report.";
29521
- const warning = weakLocationProviders.length > 0 ? `<div class="scope-warning">
29522
- <strong>Location handling needs review</strong>
29523
- ${escapeHtml2(compactInlineList(weakLocationProviders, 4))} used weak or indirect market handling. Treat provider-level differences cautiously.
29574
+ const scope = reportMarketScope(report);
29575
+ if (!scope) return "";
29576
+ const copy = REPORT_SECTION_COPY["executive-summary"].marketScope;
29577
+ const warning = scope.weakProviders !== null ? `<div class="scope-warning">
29578
+ <strong>${copy.warningTitle}</strong>
29579
+ ${escapeHtml2(scope.weakProviders)} ${copy.warningDetail}
29524
29580
  </div>` : "";
29525
29581
  return `<div class="chart-card market-scope-card">
29526
- <h3>Market Scope</h3>
29582
+ <h3>${copy.heading}</h3>
29527
29583
  <div class="market-scope-grid">
29528
29584
  <div class="scope-tile">
29529
- <div class="scope-label">Current check</div>
29530
- <div class="scope-value">${escapeHtml2(marketValue)}</div>
29531
- <div class="scope-copy">All findings below are scoped to this run.</div>
29585
+ <div class="scope-label">${copy.currentLabel}</div>
29586
+ <div class="scope-value">${escapeHtml2(scope.currentValue)}</div>
29587
+ <div class="scope-copy">${copy.currentCopy}</div>
29532
29588
  </div>
29533
29589
  <div class="scope-tile">
29534
- <div class="scope-label">Not included</div>
29535
- <div class="scope-value">${escapeHtml2(notIncluded)}</div>
29536
- <div class="scope-copy">${escapeHtml2(interpretation)}</div>
29590
+ <div class="scope-label">${copy.notIncludedLabel}</div>
29591
+ <div class="scope-value">${escapeHtml2(scope.notIncludedValue)}</div>
29592
+ <div class="scope-copy">${escapeHtml2(scope.notIncludedCopy)}</div>
29537
29593
  </div>
29538
29594
  <div class="scope-tile">
29539
- <div class="scope-label">Provider context</div>
29540
- <div class="scope-value">${handling.length > 0 ? formatNumber(handling.length) : "\u2014"}</div>
29541
- <div class="scope-copy">${escapeHtml2(providerCopy)}</div>
29595
+ <div class="scope-label">${copy.providerLabel}</div>
29596
+ <div class="scope-value">${scope.providerValue}</div>
29597
+ <div class="scope-copy">${escapeHtml2(scope.providerCopy)}</div>
29542
29598
  </div>
29543
29599
  </div>
29544
29600
  ${warning}
@@ -29546,71 +29602,65 @@ function renderLocationCard(report) {
29546
29602
  }
29547
29603
  function renderExecutiveSummary(report) {
29548
29604
  const s = report.executiveSummary;
29549
- const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
29550
- const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
29551
- const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
29552
- const citedFragment = (s.totalQueryCount ?? 0) > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
29553
- const mentionedFragment = (s.totalQueryCount ?? 0) > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
29554
- const headlineTitle = (s.totalQueryCount ?? 0) > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
29555
- const headlineSubtitle = (s.totalQueryCount ?? 0) > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a check to populate the first citation and mention baseline.";
29556
- const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
29557
- const actionCount = dedupeReportActions(report, priorityActions).length;
29605
+ const copy = REPORT_SECTION_COPY["executive-summary"];
29606
+ const headline = reportExecutiveHeadline(report);
29558
29607
  const heroHtml = `<div class="executive-hero">
29559
29608
  <div class="headline-card">
29560
29609
  <div>
29561
- <div class="hero-kicker">Latest AI visibility check</div>
29562
- <div class="hero-title">${escapeHtml2(headlineTitle)}</div>
29610
+ <div class="hero-kicker">${copy.heroKicker}</div>
29611
+ <div class="hero-title">${escapeHtml2(headline.title)}</div>
29563
29612
  </div>
29564
- <div class="hero-subtitle">${escapeHtml2(headlineSubtitle)}</div>
29613
+ <div class="hero-subtitle">${escapeHtml2(headline.subtitle)}</div>
29565
29614
  </div>
29566
29615
  <div class="hero-proof-grid">
29567
29616
  <div class="hero-proof">
29568
- <div class="mini-label">Citation trend</div>
29569
- <div class="mini-value tone-${trendTone}">${escapeHtml2(trendLabel)}</div>
29570
- <div class="mini-copy">${escapeHtml2(citedFragment)}</div>
29617
+ <div class="mini-label">${copy.proofTiles.citationTrend}</div>
29618
+ <div class="mini-value tone-${headline.trendTone}">${escapeHtml2(headline.trendLabel)}</div>
29619
+ <div class="mini-copy">${escapeHtml2(headline.citedFragment)}</div>
29571
29620
  </div>
29572
29621
  <div class="hero-proof">
29573
- <div class="mini-label">Mention coverage</div>
29622
+ <div class="mini-label">${copy.proofTiles.mentionCoverage}</div>
29574
29623
  <div class="mini-value">${s.mentionRate}%</div>
29575
- <div class="mini-copy">${escapeHtml2(mentionedFragment)}</div>
29624
+ <div class="mini-copy">${escapeHtml2(headline.mentionedFragment)}</div>
29576
29625
  </div>
29577
29626
  <div class="hero-proof">
29578
- <div class="mini-label">Prioritized actions</div>
29579
- <div class="mini-value">${formatNumber(actionCount)}</div>
29580
- <div class="mini-copy">Sorted for agency follow-up.</div>
29627
+ <div class="mini-label">${copy.proofTiles.prioritizedActions}</div>
29628
+ <div class="mini-value">${formatNumber(headline.prioritizedActionCount)}</div>
29629
+ <div class="mini-copy">${copy.prioritizedActionsCopy}</div>
29581
29630
  </div>
29582
29631
  </div>
29583
29632
  </div>`;
29584
29633
  const metrics = [
29585
29634
  {
29586
- label: "Citation rate",
29635
+ label: copy.tiles.citationRate,
29587
29636
  value: `${s.citationRate}%`,
29588
- delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${citedFragment} \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
29637
+ delta: `<span class="tone-${headline.trendTone}">${headline.trendLabel}</span> \xB7 ${headline.citedFragment} \xB7 ${headline.providerCountLabel}`
29589
29638
  },
29590
29639
  {
29591
- label: "Mention rate",
29640
+ label: copy.tiles.mentionRate,
29592
29641
  value: `${s.mentionRate}%`,
29593
- delta: mentionedFragment
29642
+ delta: headline.mentionedFragment
29594
29643
  },
29595
29644
  {
29596
- label: "Queries tracked",
29645
+ label: copy.tiles.queriesTracked,
29597
29646
  value: formatNumber(s.queryCount),
29598
- delta: `${s.competitorCount} competitor${s.competitorCount === 1 ? "" : "s"} tracked`
29647
+ delta: headline.competitorCountLabel
29599
29648
  }
29600
29649
  ];
29601
- if (s.gsc) {
29602
- const dateRange = gscDateRange(report);
29650
+ if (s.gsc && headline.gscDelta !== null) {
29603
29651
  metrics.push({
29604
- label: "GSC clicks",
29652
+ label: copy.tiles.gscClicks,
29605
29653
  value: formatNumber(s.gsc.clicks),
29606
- delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR${dateRange ? ` \xB7 ${escapeHtml2(dateRange)}` : ""}`
29654
+ // Only the date range can hold markup; the counts and separators cannot,
29655
+ // so escaping the whole line matches escaping the range alone.
29656
+ delta: escapeHtml2(headline.gscDelta)
29607
29657
  });
29608
29658
  }
29609
- if (s.ga) {
29659
+ if (s.ga && headline.gaDelta !== null) {
29610
29660
  metrics.push({
29611
- label: "GA sessions",
29661
+ label: copy.tiles.gaSessions,
29612
29662
  value: formatNumber(s.ga.sessions),
29613
- delta: `${formatNumber(s.ga.users)} users \xB7 ${formatDate(s.ga.periodStart)} \u2192 ${formatDate(s.ga.periodEnd)}`
29663
+ delta: headline.gaDelta
29614
29664
  });
29615
29665
  }
29616
29666
  const metricsHtml = `<div class="metric-grid">
@@ -29628,44 +29678,38 @@ function renderExecutiveSummary(report) {
29628
29678
  const locationHtml = renderLocationCard(report);
29629
29679
  return section(
29630
29680
  {
29631
- id: "executive-summary",
29632
- eyebrow: "Section 1",
29633
- title: "Executive Summary",
29634
- intro: "Citation = source list. Mention = answer text. They are independent signals."
29681
+ id: ReportSectionIds["executive-summary"],
29682
+ eyebrow: copy.eyebrow,
29683
+ title: copy.title,
29684
+ intro: copy.intro
29635
29685
  },
29636
29686
  heroHtml + metricsHtml + findingsHtml + locationHtml
29637
29687
  );
29638
29688
  }
29639
29689
  function deltaToneClass(direction) {
29640
- if (direction === "up") return "tone-positive";
29641
- if (direction === "down") return "tone-negative";
29642
- return "";
29643
- }
29644
- function deltaArrow(direction) {
29645
- if (direction === "up") return "\u2191";
29646
- if (direction === "down") return "\u2193";
29647
- return "\u2192";
29690
+ const tone = reportDirectionTone(direction);
29691
+ return tone === "neutral" ? "" : `tone-${tone}`;
29648
29692
  }
29649
29693
  function renderRateDeltaTile(label, delta, unit) {
29650
29694
  if (!delta) {
29651
- return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">No prior data</div></div>`;
29695
+ return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">${REPORT_SECTION_COPY["whats-changed"].rateTileEmpty}</div></div>`;
29652
29696
  }
29653
29697
  const valueSuffix = unit === "%" ? "%" : "";
29654
- const deltaText = unit === "%" ? `${delta.deltaAbs > 0 ? "+" : ""}${delta.deltaAbs.toFixed(1)}% vs ${delta.prior}%` : formatAverageDelta(delta);
29698
+ const deltaText = reportRateDeltaCopy(delta, unit);
29655
29699
  return `<div class="metric">
29656
29700
  <div class="label">${escapeHtml2(label)}</div>
29657
- <div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
29701
+ <div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
29658
29702
  <div class="delta">${deltaText}</div>
29659
29703
  </div>`;
29660
29704
  }
29661
29705
  function renderTrafficDeltaTile(label, delta, countLabel, comparisonWindowDays) {
29662
29706
  if (!delta) {
29663
- return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">Not enough trend data</div></div>`;
29707
+ return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">${REPORT_SECTION_COPY["whats-changed"].trafficTileEmpty}</div></div>`;
29664
29708
  }
29665
- const deltaText = formatWindowCountDelta(delta, countLabel, `vs prior ${comparisonWindowDays} days`);
29709
+ const deltaText = formatWindowCountDelta(delta, countLabel, reportPriorWindowLabel(comparisonWindowDays));
29666
29710
  return `<div class="metric">
29667
29711
  <div class="label">${escapeHtml2(label)}</div>
29668
- <div class="value ${deltaToneClass(delta.direction)}">${formatNumber(delta.current)} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
29712
+ <div class="value ${deltaToneClass(delta.direction)}">${formatNumber(delta.current)} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
29669
29713
  <div class="delta">${deltaText}</div>
29670
29714
  </div>`;
29671
29715
  }
@@ -29673,24 +29717,21 @@ function renderProviderMovements(movements, audience) {
29673
29717
  const meaningful = movements.filter((m) => m.direction !== "flat");
29674
29718
  if (meaningful.length === 0) return "";
29675
29719
  const isClient = audience === "client";
29720
+ const copy = isClient ? REPORT_SECTION_COPY["whats-changed"].client : REPORT_SECTION_COPY["whats-changed"].agency;
29676
29721
  const rows = meaningful.map((m) => {
29677
- const sign = m.deltaAbs > 0 ? "+" : "";
29678
29722
  return `<tr>
29679
- <td>${escapeHtml2(isClient ? providerDisplayName(m.provider) : m.provider)}</td>
29723
+ <td>${escapeHtml2(isClient ? reportProviderDisplayName(m.provider) : m.provider)}</td>
29680
29724
  <td class="numeric">${m.prior}%</td>
29681
29725
  <td class="numeric">${m.current}%</td>
29682
- <td class="numeric ${deltaToneClass(m.direction)}">${sign}${m.deltaAbs.toFixed(1)}% ${deltaArrow(m.direction)}</td>
29726
+ <td class="numeric ${deltaToneClass(m.direction)}">${reportMovementChangeCopy(m)}</td>
29683
29727
  </tr>`;
29684
29728
  }).join("");
29685
- const heading = isClient ? "How each AI tool changed" : "AI engine movements";
29686
- const colA = isClient ? "AI tool" : "Engine";
29687
- const colB = isClient ? "Was" : "Prior";
29688
- const colC = isClient ? "Now" : "Current";
29689
- return `<div class="chart-card"><h3>${heading}</h3>
29690
- <table class="report-table">
29691
- <thead><tr><th>${colA}</th><th class="numeric">${colB}</th><th class="numeric">${colC}</th><th class="numeric">Change</th></tr></thead>
29729
+ const [engineHeader, priorHeader, currentHeader, changeHeader] = copy.movementHeaders;
29730
+ return `<div class="chart-card"><h3>${copy.movementsHeading}</h3>
29731
+ <div class="table-scroll"><table class="report-table">
29732
+ <thead><tr><th>${engineHeader}</th><th class="numeric">${priorHeader}</th><th class="numeric">${currentHeader}</th><th class="numeric">${changeHeader}</th></tr></thead>
29692
29733
  <tbody>${rows}</tbody>
29693
- </table>
29734
+ </table></div>
29694
29735
  </div>`;
29695
29736
  }
29696
29737
  function renderWinsLosses(insights2, heading, emptyMessage, audience) {
@@ -29701,52 +29742,45 @@ function renderWinsLosses(insights2, heading, emptyMessage, audience) {
29701
29742
  }
29702
29743
  const isClient = audience === "client";
29703
29744
  const rows = insights2.map((i) => {
29704
- const tone = severityTone(i.severity);
29705
- const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">\xD7 ${i.instanceCount}</span>` : "";
29745
+ const tone = reportInsightTone(i);
29746
+ const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">${reportInstanceCountLabel(i.instanceCount)}</span>` : "";
29706
29747
  const severityCell = isClient ? "" : `<td><span class="badge tone-${tone}">${escapeHtml2(reportSeverityLabel(i.severity))}</span></td>`;
29707
29748
  return `<tr>
29708
29749
  ${severityCell}
29709
29750
  <td>${escapeHtml2(i.title)}${countChip}</td>
29710
29751
  <td>${escapeHtml2(i.query)}</td>
29711
- <td>${escapeHtml2(isClient ? providerDisplayName(i.provider) : i.provider)}</td>
29752
+ <td>${escapeHtml2(isClient ? reportProviderDisplayName(i.provider) : i.provider)}</td>
29712
29753
  </tr>`;
29713
29754
  }).join("");
29714
- const headers = isClient ? `<tr><th>What changed</th><th>Customer query</th><th>AI tool</th></tr>` : `<tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th></tr>`;
29755
+ const headerCells = (isClient ? REPORT_SECTION_COPY["whats-changed"].client : REPORT_SECTION_COPY["whats-changed"].agency).insightHeaders;
29756
+ const headers = `<tr>${headerCells.map((header) => `<th>${header}</th>`).join("")}</tr>`;
29715
29757
  return `<div class="chart-card"><h3>${escapeHtml2(heading)}</h3>
29716
- <table class="report-table">
29758
+ <div class="table-scroll"><table class="report-table">
29717
29759
  <thead>${headers}</thead>
29718
29760
  <tbody>${rows}</tbody>
29719
- </table>
29761
+ </table></div>
29720
29762
  </div>`;
29721
29763
  }
29722
29764
  function renderWhatsChanged(report, audience) {
29723
29765
  const w = report.whatsChanged;
29724
29766
  const isClient = audience === "client";
29725
- const eyebrow = isClient ? "Since last check" : "Section 2";
29726
- const title = isClient ? "What's different since last check" : "What's Changed";
29727
- const intro = isClient ? "" : w.headline;
29767
+ const { client, agency } = REPORT_SECTION_COPY["whats-changed"];
29768
+ const copy = isClient ? client : agency;
29769
+ const heading = { id: ReportSectionIds["whats-changed"], eyebrow: copy.eyebrow, title: copy.title, intro: isClient ? "" : w.headline };
29728
29770
  if (!w.enoughHistory && !w.gscClicksDelta && !w.aiReferralsDelta && w.wins.length === 0 && w.regressions.length === 0) {
29729
- return section(
29730
- { id: "whats-changed", eyebrow, title, intro },
29731
- renderEmpty(isClient ? "No comparison yet \u2014 trends will appear after a few more checks." : "Trends will appear after a few more checks.")
29732
- );
29771
+ return section(heading, renderEmpty(copy.empty));
29733
29772
  }
29734
29773
  const rateTiles = `<div class="metric-grid">
29735
- ${renderRateDeltaTile(isClient ? "AI mentions your name" : "Citation rate", isClient ? w.mentionRate : w.citationRate, "%")}
29736
- ${renderRateDeltaTile(isClient ? "AI links to your website" : "Mention rate", isClient ? w.citationRate : w.mentionRate, "%")}
29737
- ${renderRateDeltaTile(isClient ? "Queries AI mentioned you in" : "Cited queries", isClient ? w.mentionedQueryCount : w.citedQueryCount, "count")}
29738
- ${renderTrafficDeltaTile(isClient ? "Visitors from Google" : "GSC clicks", w.gscClicksDelta, isClient ? "visits" : "clicks", w.comparisonWindowDays)}
29739
- ${renderTrafficDeltaTile(isClient ? "Visitors from AI tools" : "AI referral sessions", w.aiReferralsDelta, isClient ? "visits" : "sessions", w.comparisonWindowDays)}
29774
+ ${renderRateDeltaTile(isClient ? client.tiles.mentionRate : agency.tiles.citationRate, isClient ? w.mentionRate : w.citationRate, "%")}
29775
+ ${renderRateDeltaTile(isClient ? client.tiles.citationRate : agency.tiles.mentionRate, isClient ? w.citationRate : w.mentionRate, "%")}
29776
+ ${renderRateDeltaTile(isClient ? client.tiles.mentionedQueryCount : agency.tiles.citedQueryCount, isClient ? w.mentionedQueryCount : w.citedQueryCount, "count")}
29777
+ ${renderTrafficDeltaTile(copy.tiles.gscClicks, w.gscClicksDelta, copy.gscCountLabel, w.comparisonWindowDays)}
29778
+ ${renderTrafficDeltaTile(copy.tiles.aiReferrals, w.aiReferralsDelta, copy.aiReferralsCountLabel, w.comparisonWindowDays)}
29740
29779
  </div>`;
29741
29780
  const movements = renderProviderMovements(w.providerMovements, audience);
29742
- const winsHeading = isClient ? "What got better" : "Wins";
29743
- const lossesHeading = isClient ? "What got worse" : "Regressions";
29744
- const wins = renderWinsLosses(w.wins, winsHeading, isClient ? "No new wins this period." : "No new gains in the latest check.", audience);
29745
- const regressions = renderWinsLosses(w.regressions, lossesHeading, isClient ? "Nothing got worse this period." : "No new regressions in the latest check.", audience);
29746
- return section(
29747
- { id: "whats-changed", eyebrow, title, intro },
29748
- `${rateTiles}${movements}${wins}${regressions}`
29749
- );
29781
+ const wins = renderWinsLosses(w.wins, copy.winsHeading, copy.winsEmpty, audience);
29782
+ const regressions = renderWinsLosses(w.regressions, copy.regressionsHeading, copy.regressionsEmpty, audience);
29783
+ return section(heading, `${rateTiles}${movements}${wins}${regressions}`);
29750
29784
  }
29751
29785
  function renderProviderBars(rates) {
29752
29786
  if (rates.length === 0) return "";
@@ -29765,45 +29799,50 @@ function renderProviderBars(rates) {
29765
29799
  <text x="${labelWidth - 8}" y="${y + 16}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml2(r.provider)}</text>
29766
29800
  <rect x="${labelWidth}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${COLORS.border}" opacity="0.4" rx="3" />
29767
29801
  <rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
29768
- <text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${r.citationRate}% (${r.citedCount}/${r.totalCount})</text>`;
29802
+ <text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${reportProviderRateLabel(r)}</text>`;
29769
29803
  }).join("");
29804
+ const title = REPORT_SECTION_COPY["citation-scorecard"].providerChartTitle;
29770
29805
  return `<div class="chart-card">
29771
- <h3>Provider citation rate</h3>
29772
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Provider citation rate bar chart">
29806
+ <h3>${title}</h3>
29807
+ <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${reportBarChartLabel(title)}">
29773
29808
  ${bars}
29774
29809
  </svg>
29775
29810
  </div>`;
29776
29811
  }
29777
29812
  function renderCitationMatrix(scorecard) {
29813
+ const copy = REPORT_SECTION_COPY["citation-scorecard"];
29814
+ const glyphs = copy.glyphs;
29778
29815
  if (scorecard.queries.length === 0 || scorecard.providers.length === 0) {
29779
- return renderEmpty("Run a check to populate the citation matrix.");
29816
+ return renderEmpty(copy.empty);
29780
29817
  }
29781
29818
  const headers = scorecard.providers.map((p) => `<th>${escapeHtml2(p)}</th>`).join("");
29782
29819
  const rows = scorecard.queries.map((q, qi) => {
29783
29820
  const cells = scorecard.providers.map((_, pi) => {
29784
29821
  const cell = scorecard.matrix[qi]?.[pi];
29785
29822
  if (!cell) {
29786
- return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
29823
+ return `<td><span class="cell-pending">${glyphs.missingCell}</span></td>`;
29787
29824
  }
29788
- const citedGlyph = cell.citationState === CitationStates.cited ? '<span class="cell-cited">C</span>' : '<span class="cell-not-cited">c</span>';
29789
- const mentionedGlyph = cell.answerMentioned === true ? '<span class="cell-cited">M</span>' : cell.answerMentioned === false ? '<span class="cell-not-cited">m</span>' : '<span class="cell-pending">\u2013</span>';
29825
+ const citedGlyph = cell.citationState === CitationStates.cited ? `<span class="cell-cited">${glyphs.cited}</span>` : `<span class="cell-not-cited">${glyphs.notCited}</span>`;
29826
+ const mentionedGlyph = cell.answerMentioned === true ? `<span class="cell-cited">${glyphs.mentioned}</span>` : cell.answerMentioned === false ? `<span class="cell-not-cited">${glyphs.notMentioned}</span>` : `<span class="cell-pending">${glyphs.pending}</span>`;
29790
29827
  return `<td>${citedGlyph} ${mentionedGlyph}</td>`;
29791
29828
  }).join("");
29792
29829
  return `<tr><td>${escapeHtml2(q)}</td>${cells}</tr>`;
29793
29830
  }).join("");
29794
- const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">Legend: <span class="cell-cited">C</span>/<span class="cell-not-cited">c</span> = cited/not, <span class="cell-cited">M</span>/<span class="cell-not-cited">m</span> = mentioned/not, <span class="cell-pending">\u2013</span> = no data.</p>';
29795
- return `${legend}<table class="report-table">
29796
- <thead><tr><th>Query</th>${headers}</tr></thead>
29831
+ const legendCopy = copy.legend;
29832
+ const legend = `<p class="section-intro" style="margin-top:0;font-size:11px;">${legendCopy.lead} <span class="cell-cited">${glyphs.cited}</span>/<span class="cell-not-cited">${glyphs.notCited}</span> ${legendCopy.citedMeaning} <span class="cell-cited">${glyphs.mentioned}</span>/<span class="cell-not-cited">${glyphs.notMentioned}</span> ${legendCopy.mentionedMeaning} <span class="cell-pending">${glyphs.pending}</span> ${legendCopy.pendingMeaning}</p>`;
29833
+ return `${legend}<div class="table-scroll"><table class="report-table">
29834
+ <thead><tr><th>${copy.queryHeader}</th>${headers}</tr></thead>
29797
29835
  <tbody>${rows}</tbody>
29798
- </table>`;
29836
+ </table></div>`;
29799
29837
  }
29800
29838
  function renderCitationScorecard(report) {
29801
29839
  const body = `
29802
29840
  ${renderProviderBars(report.citationScorecard.providerRates)}
29803
29841
  ${renderCitationMatrix(report.citationScorecard)}
29804
29842
  `;
29843
+ const copy = REPORT_SECTION_COPY["citation-scorecard"];
29805
29844
  return section(
29806
- { id: "citation-scorecard", eyebrow: "Section 3", title: "Citation Scorecard", intro: "Per-engine citation and mention coverage from the latest check." },
29845
+ { id: ReportSectionIds["citation-scorecard"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
29807
29846
  body
29808
29847
  );
29809
29848
  }
@@ -29835,19 +29874,16 @@ function renderCompetitorBars(landscape, canonical) {
29835
29874
  { label: canonical, count: landscape.projectCitationCount, isProject: true },
29836
29875
  ...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
29837
29876
  ];
29838
- return renderLandscapeBars(data, "Citations per domain", "Citations per domain bar chart");
29877
+ const title = REPORT_SECTION_COPY["competitor-landscape"].citationsChartTitle;
29878
+ return renderLandscapeBars(data, title, reportBarChartLabel(title));
29839
29879
  }
29840
29880
  function renderMentionBars(section2, canonical, title) {
29841
29881
  const data = [
29842
29882
  { label: canonical, count: section2.projectMentionCount, isProject: true },
29843
29883
  ...section2.competitors.map((c) => ({ label: c.domain, count: c.mentionCount, isProject: false }))
29844
29884
  ];
29845
- return renderLandscapeBars(data, title, `${title} bar chart`);
29885
+ return renderLandscapeBars(data, title, reportBarChartLabel(title));
29846
29886
  }
29847
- var MENTION_SCOPE_LABEL = {
29848
- "non-brand": "non-brand queries",
29849
- pooled: "pooled queries \xB7 classification unavailable"
29850
- };
29851
29887
  function renderReportShareOfVoice(report) {
29852
29888
  return [report.mentionLandscape.nonBrand?.shareOfVoice, report.mentionLandscape.branded?.shareOfVoice?.queryClass === "branded" ? report.mentionLandscape.branded.shareOfVoice : void 0].map((share) => share ? `<div class="chart-note"><p>${escapeHtml2(shareOfVoiceSummary(share.percent, share.queryClass, share))}</p>${share.reason ? `<p>${escapeHtml2(shareOfVoiceReason(share.reason))}</p>` : ""}</div>` : "").join("");
29853
29889
  }
@@ -29858,19 +29894,20 @@ function renderCompetitorLandscape(report) {
29858
29894
  const brandedMentions = mentionLandscape.branded;
29859
29895
  const hasBrandedMentions = (brandedMentions?.totalAnswerSnapshots ?? 0) > 0;
29860
29896
  const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0 && !hasBrandedMentions;
29897
+ const copy = REPORT_SECTION_COPY["competitor-landscape"];
29861
29898
  if (noCitationData && noMentionData) {
29862
29899
  return section(
29863
- { id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
29864
- renderEmpty("No competitor data yet. Add competitors and run a check.")
29900
+ { id: ReportSectionIds["competitor-landscape"], eyebrow: copy.eyebrow, title: copy.title },
29901
+ renderEmpty(copy.empty)
29865
29902
  );
29866
29903
  }
29867
29904
  const mentionByDomain = new Map(mentionLandscape.competitors.map((m) => [m.domain, m]));
29868
29905
  const rows = competitors2.map((c) => {
29869
- const tone = pressureTone2(c.pressureLabel);
29906
+ const tone = reportPressureTone(c.pressureLabel);
29870
29907
  const mention = mentionByDomain.get(c.domain);
29871
29908
  const mentionCount = mention?.mentionCount ?? 0;
29872
29909
  const mentionTotal = mention?.totalCount ?? mentionLandscape.totalAnswerSnapshots;
29873
- const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${c.theirCitedPages.length} cited URL${c.theirCitedPages.length > 1 ? "s" : ""}</summary>
29910
+ const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${reportCitedUrlCount(c.theirCitedPages.length)}</summary>
29874
29911
  <ul>${c.theirCitedPages.map((p) => `<li><a href="${safeHref(p.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml2(p.url)}</a> <span class="cited-for">${escapeHtml2(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
29875
29912
  </details>` : "";
29876
29913
  return `<tr>
@@ -29879,52 +29916,39 @@ function renderCompetitorLandscape(report) {
29879
29916
  <td class="numeric">${c.citationCount} / ${c.totalCount}</td>
29880
29917
  <td class="numeric">${mentionCount} / ${mentionTotal}</td>
29881
29918
  <td class="numeric">${c.sharePct}%</td>
29882
- <td>${escapeHtml2(c.citedQueries.slice(0, 5).join(", "))}${c.citedQueries.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
29919
+ <td>${escapeHtml2(reportTruncatedList(c.citedQueries, 5))}${pagesDisclosure}</td>
29883
29920
  </tr>`;
29884
29921
  }).join("");
29885
- const scopeLabel2 = MENTION_SCOPE_LABEL[mentionLandscape.scope] ?? MENTION_SCOPE_LABEL.pooled;
29886
- const mentionScopeTooltip = mentionLandscape.scope === "non-brand" ? `Mentions on ${scopeLabel2}. Branded queries are counted separately \u2014 the client is named on nearly all of them and a competitor cannot be, so pooling the two would rank the client on its own brand recall.` : `Mentions on ${scopeLabel2}. The project has no usable brand identity for a branded/non-brand split, so all tracked queries remain pooled and this is not a competitive category read.`;
29887
- const table = competitors2.length > 0 ? `<table class="report-table">
29888
- <thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric" title="${escapeHtml2(mentionScopeTooltip)}">Mentions (${escapeHtml2(scopeLabel2)})</th><th class="numeric" title="Citation share \u2014 % of cited-source slots that went to this competitor across tracked queries. Distinct from Mention Share.">Citation share</th><th>Cited queries</th></tr></thead>
29922
+ const mentionCopy = reportCompetitorMentionCopy(mentionLandscape);
29923
+ const headers = copy.headers;
29924
+ const table = competitors2.length > 0 ? `<div class="table-scroll"><table class="report-table">
29925
+ <thead><tr><th>${headers.domain}</th><th>${headers.pressure}</th><th>${headers.citations}</th><th class="numeric" title="${escapeHtml2(mentionCopy.mentionsTooltip)}">${escapeHtml2(mentionCopy.mentionsHeader)}</th><th class="numeric" title="${copy.citationShareTooltip}">${headers.citationShare}</th><th>${headers.citedQueries}</th></tr></thead>
29889
29926
  <tbody>${rows}</tbody>
29890
- </table>` : renderEmpty("No competitors configured.");
29927
+ </table></div>` : renderEmpty(copy.noCompetitors);
29891
29928
  const citationBars = renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain);
29892
29929
  const mentionBars = renderMentionBars(
29893
29930
  mentionLandscape,
29894
29931
  report.meta.project.canonicalDomain,
29895
- `Mentions per domain \xB7 ${scopeLabel2}`
29932
+ mentionCopy.mentionsChartTitle
29896
29933
  );
29897
29934
  const charts = citationBars && mentionBars ? `<div class="chart-grid">${citationBars}${mentionBars}</div>` : `${citationBars}${mentionBars}`;
29898
29935
  const mentionShareUnavailable = !mentionLandscape.nonBrand?.shareOfVoice && mentionLandscape.projectMentionCount === 0 && mentionLandscape.competitors.length > 0 && mentionLandscape.competitors.every((row) => row.sharePct === null);
29899
- const mentionShareNote = mentionShareUnavailable ? `<p class="chart-note">Mention share unavailable for ${escapeHtml2(scopeLabel2)}: no tracked brand was named, so the denominator is 0.</p>` : "";
29900
- const brandedBars = hasBrandedMentions && brandedMentions ? renderMentionBars(brandedMentions, report.meta.project.canonicalDomain, "Mentions per domain \xB7 branded queries") : "";
29936
+ const mentionShareNote = mentionShareUnavailable ? `<p class="chart-note">${escapeHtml2(mentionCopy.mentionShareUnavailable)}</p>` : "";
29937
+ const brandedBars = hasBrandedMentions && brandedMentions ? renderMentionBars(brandedMentions, report.meta.project.canonicalDomain, copy.brandedChartTitle) : "";
29901
29938
  const brandedBlock = brandedBars ? `<div class="mention-branded-block">
29902
- <p class="chart-note">Branded queries contain the client's own name. The client is named on nearly all of them and a competitor structurally cannot be, so these are kept out of the competitive figure above. Read them as brand recall: ${brandedMentions?.projectMentionCount ?? 0} of ${brandedMentions?.totalAnswerSnapshots ?? 0} branded answers named the client.</p>
29939
+ <p class="chart-note">${mentionCopy.brandedNote}</p>
29903
29940
  ${brandedBars}
29904
29941
  </div>` : "";
29905
29942
  return section(
29906
29943
  {
29907
- id: "competitor-landscape",
29908
- eyebrow: "Section 4",
29909
- title: "Competitor Landscape",
29910
- intro: "Who AI engines cite and mention instead of the client."
29944
+ id: ReportSectionIds["competitor-landscape"],
29945
+ eyebrow: copy.eyebrow,
29946
+ title: copy.title,
29947
+ intro: copy.intro
29911
29948
  },
29912
29949
  `${mentionShareNote}${charts}${table}${brandedBlock}`
29913
29950
  );
29914
29951
  }
29915
- var SOURCE_CATEGORY_TONE = {
29916
- competitor: "negative",
29917
- directory: "caution",
29918
- forum: "caution",
29919
- news: "neutral",
29920
- reference: "neutral",
29921
- blog: "neutral",
29922
- social: "neutral",
29923
- video: "neutral",
29924
- ecommerce: "neutral",
29925
- academic: "neutral",
29926
- other: "neutral"
29927
- };
29928
29952
  function renderCategoryBars(buckets) {
29929
29953
  if (buckets.length === 0) return "";
29930
29954
  const total = buckets.reduce((s, b) => s + b.count, 0);
@@ -29932,7 +29956,7 @@ function renderCategoryBars(buckets) {
29932
29956
  const max2 = Math.max(...buckets.map((b) => b.count), 1);
29933
29957
  const rows = buckets.map((b) => {
29934
29958
  const pct = b.count / max2 * 100;
29935
- const tone = SOURCE_CATEGORY_TONE[b.category] ?? "neutral";
29959
+ const tone = reportSourceCategoryTone(b.category);
29936
29960
  const color = tone === "negative" ? COLORS.negative : tone === "caution" ? COLORS.caution : COLORS.accent;
29937
29961
  return `
29938
29962
  <div class="source-bar-row">
@@ -29940,11 +29964,11 @@ function renderCategoryBars(buckets) {
29940
29964
  <div class="source-bar-track">
29941
29965
  <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
29942
29966
  </div>
29943
- <div class="source-bar-value">${b.count} <span class="source-bar-pct">(${b.sharePct}%)</span></div>
29967
+ <div class="source-bar-value">${b.count} <span class="source-bar-pct">${reportSourceCategoryShareLabel(b.sharePct)}</span></div>
29944
29968
  </div>`;
29945
29969
  }).join("");
29946
29970
  return `<div class="chart-card">
29947
- <h3>By source type</h3>
29971
+ <h3>${REPORT_SECTION_COPY["ai-source-origin"].categoriesHeading}</h3>
29948
29972
  <div class="source-bars">${rows}</div>
29949
29973
  </div>`;
29950
29974
  }
@@ -29960,7 +29984,7 @@ function renderShareBars(heading, rows, countLabel) {
29960
29984
  <div class="source-bar-track">
29961
29985
  <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
29962
29986
  </div>
29963
- <div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml2(countLabel)} \xB7 ${r.sharePct}%</span></div>
29987
+ <div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml2(reportShareBarShareLabel(countLabel, r.sharePct))}</span></div>
29964
29988
  </div>`;
29965
29989
  }).join("");
29966
29990
  return `<div class="chart-card">
@@ -29970,32 +29994,34 @@ function renderShareBars(heading, rows, countLabel) {
29970
29994
  }
29971
29995
  function renderAiSourceOrigin(report) {
29972
29996
  const origin = report.aiSourceOrigin;
29997
+ const copy = REPORT_SECTION_COPY["ai-source-origin"];
29973
29998
  if (origin.categories.length === 0 && origin.topDomains.length === 0) {
29974
29999
  return section(
29975
- { id: "ai-source-origin", eyebrow: "Section 5", title: "AI Citation Sources" },
29976
- renderEmpty("No source data yet. Run a check first.")
30000
+ { id: ReportSectionIds["ai-source-origin"], eyebrow: copy.eyebrow, title: copy.title },
30001
+ renderEmpty(copy.empty)
29977
30002
  );
29978
30003
  }
29979
- const competitorBucket = origin.categories.find((c) => c.category === "competitor");
29980
- const headlineFragment = competitorBucket ? `<p class="source-origin-headline"><strong>${competitorBucket.sharePct}%</strong> of citations went to tracked competitors (${competitorBucket.count} of ${origin.categories.reduce((s, c) => s + c.count, 0)}).</p>` : "";
30004
+ const headline = reportSourceOriginHeadline(origin.categories);
30005
+ const headlineFragment = headline ? `<p class="source-origin-headline"><strong>${headline.share}</strong> ${headline.detail}</p>` : "";
29981
30006
  const rows = origin.topDomains.map((d) => `
29982
30007
  <tr>
29983
30008
  <td>${escapeHtml2(d.domain)}</td>
29984
30009
  <td class="numeric">${d.count}</td>
29985
- <td>${d.isCompetitor ? '<span class="badge tone-negative">Tracked competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
30010
+ <td>${d.isCompetitor ? `<span class="badge tone-negative">${copy.trackedCompetitorTag}</span>` : `<span class="badge tone-neutral">${copy.externalTag}</span>`}</td>
29986
30011
  </tr>`).join("");
29987
- const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>Top sources</h3>
29988
- <table class="report-table">
29989
- <thead><tr><th>Domain</th><th class="numeric">Citations</th><th>Tag</th></tr></thead>
30012
+ const [domainHeader, citationsHeader, tagHeader] = copy.topSourceHeaders;
30013
+ const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>${copy.topSourcesHeading}</h3>
30014
+ <div class="table-scroll"><table class="report-table">
30015
+ <thead><tr><th>${domainHeader}</th><th class="numeric">${citationsHeader}</th><th>${tagHeader}</th></tr></thead>
29990
30016
  <tbody>${rows}</tbody>
29991
- </table>
30017
+ </table></div>
29992
30018
  </div>` : "";
29993
30019
  return section(
29994
30020
  {
29995
- id: "ai-source-origin",
29996
- eyebrow: "Section 5",
29997
- title: "AI Citation Sources",
29998
- intro: "External domains AI engines cited most in the latest check."
30021
+ id: ReportSectionIds["ai-source-origin"],
30022
+ eyebrow: copy.eyebrow,
30023
+ title: copy.title,
30024
+ intro: copy.intro
29999
30025
  },
30000
30026
  `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
30001
30027
  );
@@ -30022,7 +30048,7 @@ function renderLineChart(points, color, title, height = 200) {
30022
30048
  }).join("");
30023
30049
  return `<div class="chart-card">
30024
30050
  <h3>${escapeHtml2(title)}</h3>
30025
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml2(title)} line chart">
30051
+ <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml2(reportLineChartLabel(title))}">
30026
30052
  <line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
30027
30053
  <text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatNumber(max2)}</text>
30028
30054
  <text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">0</text>
@@ -30034,10 +30060,11 @@ function renderLineChart(points, color, title, height = 200) {
30034
30060
  }
30035
30061
  function renderGsc(report) {
30036
30062
  const gsc = report.gsc;
30063
+ const copy = REPORT_SECTION_COPY.gsc;
30037
30064
  if (!gsc) {
30038
30065
  return section(
30039
- { id: "gsc", eyebrow: "Section 6", title: "GSC Performance" },
30040
- renderEmpty("Connect Google Search Console to populate this section.")
30066
+ { id: ReportSectionIds.gsc, eyebrow: copy.eyebrow, title: copy.title },
30067
+ renderEmpty(copy.empty)
30041
30068
  );
30042
30069
  }
30043
30070
  const rows = gsc.topQueries.map((q) => `
@@ -30050,48 +30077,48 @@ function renderGsc(report) {
30050
30077
  <td><span class="badge tone-neutral">${escapeHtml2(q.category)}</span></td>
30051
30078
  </tr>`).join("");
30052
30079
  const categoryBars = renderShareBars(
30053
- "Search demand by intent",
30080
+ copy.intentHeading,
30054
30081
  gsc.categoryBreakdown.map((c, index2) => ({
30055
30082
  label: c.category,
30056
30083
  count: c.clicks,
30057
30084
  sharePct: c.sharePct,
30058
30085
  color: COLORS.series[index2 % COLORS.series.length]
30059
30086
  })),
30060
- "clicks"
30087
+ copy.intentCountLabel
30061
30088
  );
30062
30089
  const trendChart = renderLineChart(
30063
30090
  gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
30064
30091
  COLORS.accent,
30065
- "Clicks over time"
30092
+ copy.trendTitle
30066
30093
  );
30067
30094
  const crossoverBlocks = [];
30068
30095
  if (gsc.trackedButNoGsc.length > 0) {
30069
- crossoverBlocks.push(`<div class="chart-card"><h3>AEO queries without search demand</h3>
30070
- <p class="section-intro">Review whether these still belong in the tracking set.</p>
30096
+ crossoverBlocks.push(`<div class="chart-card"><h3>${copy.untrackedDemand.heading}</h3>
30097
+ <p class="section-intro">${copy.untrackedDemand.subtitle}</p>
30071
30098
  ${renderProofChips(gsc.trackedButNoGsc, 6)}
30072
30099
  </div>`);
30073
30100
  }
30074
30101
  if (gsc.gscButNotTracked.length > 0) {
30075
- crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
30076
- <p class="section-intro">High-impression candidates to add to AEO tracking.</p>
30102
+ crossoverBlocks.push(`<div class="chart-card"><h3>${copy.suggestedQueries.heading}</h3>
30103
+ <p class="section-intro">${copy.suggestedQueries.subtitle}</p>
30077
30104
  ${renderProofChips(gsc.gscButNotTracked, 6)}
30078
30105
  </div>`);
30079
30106
  }
30080
- const dateRange = gscDateRange(report);
30107
+ const [queryHeader, clicksHeader, impressionsHeader, ctrHeader, positionHeader, categoryHeader] = copy.topQueryHeaders;
30081
30108
  return section(
30082
- { id: "gsc", eyebrow: "Section 6", title: "GSC Performance", intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ""}.` },
30109
+ { id: ReportSectionIds.gsc, eyebrow: copy.eyebrow, title: copy.title, intro: reportGscIntro(report) },
30083
30110
  `<div class="metric-grid">
30084
- <div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
30085
- <div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
30086
- <div class="metric"><div class="label">Avg CTR</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
30087
- <div class="metric"><div class="label">Avg position</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
30111
+ <div class="metric"><div class="label">${copy.tiles.clicks}</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
30112
+ <div class="metric"><div class="label">${copy.tiles.impressions}</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
30113
+ <div class="metric"><div class="label">${copy.tiles.ctr}</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
30114
+ <div class="metric"><div class="label">${copy.tiles.position}</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
30088
30115
  </div>
30089
30116
  ${trendChart}
30090
- <div class="chart-card"><h3>Top queries</h3>
30091
- <table class="report-table">
30092
- <thead><tr><th>Query</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">CTR</th><th class="numeric">Pos.</th><th>Category</th></tr></thead>
30117
+ <div class="chart-card"><h3>${copy.topQueriesHeading}</h3>
30118
+ <div class="table-scroll"><table class="report-table">
30119
+ <thead><tr><th>${queryHeader}</th><th class="numeric">${clicksHeader}</th><th class="numeric">${impressionsHeader}</th><th class="numeric">${ctrHeader}</th><th class="numeric">${positionHeader}</th><th>${categoryHeader}</th></tr></thead>
30093
30120
  <tbody>${rows}</tbody>
30094
- </table>
30121
+ </table></div>
30095
30122
  </div>
30096
30123
  ${categoryBars}
30097
30124
  ${crossoverBlocks.join("\n")}`
@@ -30099,10 +30126,11 @@ function renderGsc(report) {
30099
30126
  }
30100
30127
  function renderGa(report) {
30101
30128
  const ga = report.ga;
30129
+ const copy = REPORT_SECTION_COPY.ga;
30102
30130
  if (!ga) {
30103
30131
  return section(
30104
- { id: "ga", eyebrow: "Section 7", title: "GA4 Traffic" },
30105
- renderEmpty("Connect Google Analytics 4 to populate this section.")
30132
+ { id: ReportSectionIds.ga, eyebrow: copy.eyebrow, title: copy.title },
30133
+ renderEmpty(copy.empty)
30106
30134
  );
30107
30135
  }
30108
30136
  const pageRows = ga.topLandingPages.map((p) => `
@@ -30112,48 +30140,50 @@ function renderGa(report) {
30112
30140
  <td class="numeric">${formatNumber(p.organicSessions)}</td>
30113
30141
  </tr>`).join("");
30114
30142
  const channelBars = renderShareBars(
30115
- "Channel mix",
30143
+ copy.channelsHeading,
30116
30144
  ga.channelBreakdown.map((c, index2) => ({
30117
30145
  label: c.channel,
30118
30146
  count: c.sessions,
30119
30147
  sharePct: c.sharePct,
30120
30148
  color: COLORS.series[index2 % COLORS.series.length]
30121
30149
  })),
30122
- "sessions"
30150
+ copy.channelsCountLabel
30123
30151
  );
30152
+ const [pageHeader, sessionsHeader, organicHeader] = copy.topPageHeaders;
30124
30153
  return section(
30125
- { id: "ga", eyebrow: "Section 7", title: "GA4 Traffic", intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` },
30154
+ { id: ReportSectionIds.ga, eyebrow: copy.eyebrow, title: copy.title, intro: reportGaIntro(ga) },
30126
30155
  `<div class="metric-grid">
30127
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
30128
- <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
30129
- <div class="metric"><div class="label">Organic sessions</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
30156
+ <div class="metric"><div class="label">${copy.tiles.sessions}</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
30157
+ <div class="metric"><div class="label">${copy.tiles.users}</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
30158
+ <div class="metric"><div class="label">${copy.tiles.organicSessions}</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
30130
30159
  </div>
30131
- <div class="chart-card"><h3>Top landing pages</h3>
30132
- <table class="report-table">
30133
- <thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Organic</th></tr></thead>
30160
+ <div class="chart-card"><h3>${copy.topPagesHeading}</h3>
30161
+ <div class="table-scroll"><table class="report-table">
30162
+ <thead><tr><th>${pageHeader}</th><th class="numeric">${sessionsHeader}</th><th class="numeric">${organicHeader}</th></tr></thead>
30134
30163
  <tbody>${pageRows}</tbody>
30135
- </table>
30164
+ </table></div>
30136
30165
  </div>
30137
30166
  ${channelBars}`
30138
30167
  );
30139
30168
  }
30140
30169
  function renderSocial(report) {
30141
30170
  const social = report.socialReferrals;
30171
+ const copy = REPORT_SECTION_COPY["social-referrals"];
30142
30172
  if (!social) {
30143
30173
  return section(
30144
- { id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals" },
30145
- renderEmpty("No social referral data yet.")
30174
+ { id: ReportSectionIds["social-referrals"], eyebrow: copy.eyebrow, title: copy.title },
30175
+ renderEmpty(copy.empty)
30146
30176
  );
30147
30177
  }
30148
30178
  const channelBars = renderShareBars(
30149
- "Social channel mix",
30179
+ copy.channelsHeading,
30150
30180
  social.channels.map((c, index2) => ({
30151
30181
  label: c.channelGroup,
30152
30182
  count: c.sessions,
30153
30183
  sharePct: c.sharePct,
30154
30184
  color: COLORS.series[index2 % COLORS.series.length]
30155
30185
  })),
30156
- "sessions"
30186
+ copy.channelsCountLabel
30157
30187
  );
30158
30188
  const campaignRows = social.topCampaigns.map((c) => `
30159
30189
  <tr>
@@ -30161,39 +30191,41 @@ function renderSocial(report) {
30161
30191
  <td>${escapeHtml2(c.medium)}</td>
30162
30192
  <td class="numeric">${formatNumber(c.sessions)}</td>
30163
30193
  </tr>`).join("");
30194
+ const [sourceHeader, mediumHeader, sessionsHeader] = copy.campaignHeaders;
30164
30195
  return section(
30165
- { id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals", intro: "Social traffic split by channel and campaign." },
30196
+ { id: ReportSectionIds["social-referrals"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30166
30197
  `<div class="metric-grid">
30167
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
30168
- <div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
30169
- <div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
30198
+ <div class="metric"><div class="label">${copy.tiles.sessions}</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
30199
+ <div class="metric"><div class="label">${copy.tiles.organic}</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
30200
+ <div class="metric"><div class="label">${copy.tiles.paid}</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
30170
30201
  </div>
30171
30202
  ${channelBars}
30172
- <div class="chart-card"><h3>Top campaigns</h3>
30173
- <table class="report-table">
30174
- <thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
30203
+ <div class="chart-card"><h3>${copy.campaignsHeading}</h3>
30204
+ <div class="table-scroll"><table class="report-table">
30205
+ <thead><tr><th>${sourceHeader}</th><th>${mediumHeader}</th><th class="numeric">${sessionsHeader}</th></tr></thead>
30175
30206
  <tbody>${campaignRows}</tbody>
30176
- </table>
30207
+ </table></div>
30177
30208
  </div>`
30178
30209
  );
30179
30210
  }
30180
30211
  function renderAiReferrals(report) {
30181
30212
  const ai = report.aiReferrals;
30213
+ const copy = REPORT_SECTION_COPY["ai-referrals"];
30182
30214
  if (!ai) {
30183
30215
  return section(
30184
- { id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic" },
30185
- renderEmpty("No AI referral traffic detected yet.")
30216
+ { id: ReportSectionIds["ai-referrals"], eyebrow: copy.eyebrow, title: copy.title },
30217
+ renderEmpty(copy.empty)
30186
30218
  );
30187
30219
  }
30188
30220
  const sourceBars = renderShareBars(
30189
- "AI sessions by source",
30221
+ copy.sourcesHeading,
30190
30222
  ai.bySource.map((s, index2) => ({
30191
30223
  label: s.source,
30192
30224
  count: s.sessions,
30193
30225
  sharePct: s.sharePct,
30194
30226
  color: COLORS.series[(index2 + 2) % COLORS.series.length]
30195
30227
  })),
30196
- "sessions"
30228
+ copy.sourcesCountLabel
30197
30229
  );
30198
30230
  const pageRows = ai.topLandingPages.map((p) => `
30199
30231
  <tr>
@@ -30203,58 +30235,50 @@ function renderAiReferrals(report) {
30203
30235
  const trendChart = renderLineChart(
30204
30236
  ai.trend.map((t) => ({ x: t.date, y: t.sessions, label: t.date.slice(5) })),
30205
30237
  COLORS.series[2],
30206
- "AI referral sessions over time"
30238
+ copy.trendTitle
30207
30239
  );
30240
+ const [pageHeader, sessionsHeader] = copy.topPageHeaders;
30208
30241
  return section(
30209
- { id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic", intro: "Traffic arriving from AI answer engines." },
30242
+ { id: ReportSectionIds["ai-referrals"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30210
30243
  `<div class="metric-grid">
30211
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
30244
+ <div class="metric"><div class="label">${copy.tiles.sessions}</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
30212
30245
  </div>
30213
30246
  ${trendChart}
30214
30247
  ${sourceBars}
30215
- <div class="chart-card"><h3>Top AI landing pages</h3>
30216
- <table class="report-table">
30217
- <thead><tr><th>Page</th><th class="numeric">Sessions</th></tr></thead>
30248
+ <div class="chart-card"><h3>${copy.topPagesHeading}</h3>
30249
+ <div class="table-scroll"><table class="report-table">
30250
+ <thead><tr><th>${pageHeader}</th><th class="numeric">${sessionsHeader}</th></tr></thead>
30218
30251
  <tbody>${pageRows}</tbody>
30219
- </table>
30252
+ </table></div>
30220
30253
  </div>`
30221
30254
  );
30222
30255
  }
30223
- function serverActivityHeading(audience, hasData, windowDays2) {
30224
- const isClient = audience === "client";
30225
- return {
30226
- id: "server-activity",
30227
- eyebrow: isClient ? "AI engine attention" : "Section 10",
30228
- title: "AI Visibility \u2014 Server-Side",
30229
- intro: isClient ? hasData ? `What AI engines actually do in your server logs over the last ${windowDays2} days \u2014 the other half of citations.` : "Live telemetry from your server logs." : "What AI engines actually do in your server logs \u2014 direct evidence, complementary to citations (which measure what they say)."
30230
- };
30231
- }
30232
30256
  function renderServerActivity(report, audience) {
30233
30257
  const sa = report.serverActivity;
30234
30258
  const isClient = audience === "client";
30235
30259
  const windowDays2 = report.meta.periodDays;
30236
- const windowLabel = `${windowDays2}d`;
30237
- const priorWindowLabel = `vs prior ${windowDays2} days`;
30260
+ const copy = REPORT_SECTION_COPY["server-activity"];
30261
+ const priorWindowLabel = reportPriorWindowLabel(windowDays2);
30238
30262
  if (!sa) {
30239
30263
  if (isClient) return "";
30240
30264
  return section(
30241
- serverActivityHeading("agency", false, windowDays2),
30242
- renderEmpty("Connect a server-side traffic source to surface what AI engines do directly in your server logs \u2014 distinct from GA4 click-throughs.")
30265
+ reportServerActivityHeading("agency", false, windowDays2),
30266
+ renderEmpty(copy.agency.emptyNotConnected)
30243
30267
  );
30244
30268
  }
30245
30269
  if (!sa.hasData) {
30246
30270
  return section(
30247
- serverActivityHeading(audience, false, windowDays2),
30248
- renderEmpty(isClient ? "Your server-side traffic source is connected. Numbers will appear after the next sync." : "Source connected \u2014 collecting your first data. Numbers will appear after the next sync.")
30271
+ reportServerActivityHeading(audience, false, windowDays2),
30272
+ renderEmpty(isClient ? copy.client.empty : copy.agency.empty)
30249
30273
  );
30250
30274
  }
30251
30275
  const formatDelta = (d, suffix) => {
30252
- const copy = formatDeltaCopy(d, suffix, priorWindowLabel);
30253
- if (!copy) return "";
30254
- return `<span class="tone-${deltaTone(d.deltaPct)}">${escapeHtml2(copy)}</span>`;
30276
+ const copy2 = formatDeltaCopy(d, suffix, priorWindowLabel);
30277
+ if (!copy2) return "";
30278
+ return `<span class="tone-${deltaTone(d.deltaPct)}">${escapeHtml2(copy2)}</span>`;
30255
30279
  };
30256
- const referralRedirectNote = sa.referralRedirects > 0 ? `${formatNumber(sa.referralRedirects)} blocked by redirects` : "";
30257
- const referralSubtitle = [formatDelta(sa.referralArrivals, "sessions"), escapeHtml2(sa.referralArrivalsClassSummary), referralRedirectNote].filter(Boolean).join(" \xB7 ");
30280
+ const referralRedirectNote = reportReferralRedirectNote(sa.referralRedirects);
30281
+ const referralSubtitle = [formatDelta(sa.referralArrivals, copy.countNouns.sessions), escapeHtml2(sa.referralArrivalsClassSummary), referralRedirectNote].filter(Boolean).join(" \xB7 ");
30258
30282
  if (isClient) {
30259
30283
  const crawlerRequests = {
30260
30284
  current: sa.verifiedCrawlerHits.current + sa.unverifiedCrawlerHits.current,
@@ -30264,11 +30288,11 @@ function renderServerActivity(report, audience) {
30264
30288
  sa.verifiedCrawlerHits.prior + sa.unverifiedCrawlerHits.prior
30265
30289
  )
30266
30290
  };
30267
- const crawlerTrustSummary = `${formatNumber(sa.verifiedCrawlerHits.current)} verified \xB7 ${formatNumber(sa.unverifiedCrawlerHits.current)} unverified`;
30268
- const crawlerDelta = formatDelta(crawlerRequests, "requests");
30291
+ const crawlerTrustSummary = reportCrawlerTrustSummary(sa.verifiedCrawlerHits.current, sa.unverifiedCrawlerHits.current);
30292
+ const crawlerDelta = formatDelta(crawlerRequests, copy.countNouns.requests);
30269
30293
  const crawlerSubtitle = crawlerDelta ? `${escapeHtml2(crawlerTrustSummary)} \xB7 ${crawlerDelta}` : escapeHtml2(crawlerTrustSummary);
30270
- const userFetchDelta = formatDelta(sa.aiUserFetchHits, "requests");
30271
- const userFetchSubtitle = userFetchDelta || escapeHtml2("ChatGPT-User, Perplexity-User, MistralAI-User");
30294
+ const userFetchDelta = formatDelta(sa.aiUserFetchHits, copy.countNouns.requests);
30295
+ const userFetchSubtitle = userFetchDelta || escapeHtml2(copy.client.userFetchFallback);
30272
30296
  const clientOperators = sa.byOperator.filter((o) => o.verifiedHits > 0 || o.unverifiedHits > 0 || o.userFetchHits > 0 || o.referralArrivals > 0).slice(0, 5);
30273
30297
  const clientOperatorRows = clientOperators.map((o) => `
30274
30298
  <tr>
@@ -30277,36 +30301,37 @@ function renderServerActivity(report, audience) {
30277
30301
  <td class="numeric">${formatNumber(o.userFetchHits)}</td>
30278
30302
  <td class="numeric">${formatNumber(o.referralArrivals)}</td>
30279
30303
  </tr>`).join("");
30304
+ const [toolHeader, botRequestsHeader, userFetchesHeader2, referralSessionsHeader2] = reportServerActivityClientOperatorHeaders(windowDays2);
30280
30305
  return section(
30281
- serverActivityHeading("client", true, windowDays2),
30306
+ reportServerActivityHeading("client", true, windowDays2),
30282
30307
  `<div class="metric-grid">
30283
30308
  <div class="metric">
30284
- <div class="label">AI bot requests observed</div>
30309
+ <div class="label">${copy.client.tiles.botRequests}</div>
30285
30310
  <div class="value">${formatNumber(crawlerRequests.current)}</div>
30286
30311
  <div class="subtitle">${crawlerSubtitle}</div>
30287
30312
  </div>
30288
30313
  <div class="metric">
30289
- <div class="label">AI user-fetch requests</div>
30314
+ <div class="label">${copy.client.tiles.userFetches}</div>
30290
30315
  <div class="value">${formatNumber(sa.aiUserFetchHits.current)}</div>
30291
30316
  <div class="subtitle">${userFetchSubtitle}</div>
30292
30317
  </div>
30293
30318
  <div class="metric">
30294
- <div class="label">AI referral sessions</div>
30319
+ <div class="label">${copy.client.tiles.referralSessions}</div>
30295
30320
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
30296
30321
  <div class="subtitle">${referralSubtitle}</div>
30297
30322
  </div>
30298
30323
  </div>
30299
- ${clientOperatorRows ? `<div class="chart-card"><h3>By AI tool</h3>
30300
- <table class="report-table">
30301
- <thead><tr><th>AI tool</th><th class="numeric">Bot requests (${windowLabel})</th><th class="numeric">User fetches (${windowLabel})</th><th class="numeric">Referral sessions</th></tr></thead>
30324
+ ${clientOperatorRows ? `<div class="chart-card"><h3>${copy.client.operatorsHeading}</h3>
30325
+ <div class="table-scroll"><table class="report-table">
30326
+ <thead><tr><th>${toolHeader}</th><th class="numeric">${botRequestsHeader}</th><th class="numeric">${userFetchesHeader2}</th><th class="numeric">${referralSessionsHeader2}</th></tr></thead>
30302
30327
  <tbody>${clientOperatorRows}</tbody>
30303
- </table>
30304
- <p class="meta">Bot requests are bulk crawl (GPTBot, PerplexityBot, \u2026). User fetches are on-demand reads triggered by real users inside an AI surface (ChatGPT-User, Perplexity-User, \u2026). Verified means the request came from an IP the operator publishes as its own; unverified means the user-agent matched but the IP is not in a published range. User-fetch totals count both, since many genuine user fetches come from outside any published range.</p>
30328
+ </table></div>
30329
+ <p class="meta">${copy.client.operatorsFootnote}</p>
30305
30330
  </div>` : ""}`
30306
30331
  );
30307
30332
  }
30308
30333
  const operatorRows = sa.byOperator.map((o) => {
30309
- const deltaText = o.deltaPct === null ? "\u2014" : `${o.deltaPct > 0 ? "+" : ""}${o.deltaPct}%`;
30334
+ const deltaText = reportServerActivityOperatorDelta(o.deltaPct);
30310
30335
  const toneClass = o.deltaPct === null ? "" : `tone-${deltaTone(o.deltaPct)}`;
30311
30336
  return `
30312
30337
  <tr>
@@ -30321,7 +30346,7 @@ function renderServerActivity(report, audience) {
30321
30346
  const pathRows = sa.topCrawledPaths.map((p) => `
30322
30347
  <tr>
30323
30348
  <td class="page-cell">${formatLandingPageHtml(p.path)}</td>
30324
- <td class="numeric">${formatNumber(p.verifiedHits + (p.unverifiedHits ?? 0))}</td>
30349
+ <td class="numeric">${formatNumber(reportServerActivityPathHits(p))}</td>
30325
30350
  <td class="numeric meta">${formatNumber(p.verifiedHits)}</td>
30326
30351
  <td class="numeric">${p.distinctOperators}</td>
30327
30352
  </tr>`).join("");
@@ -30340,75 +30365,82 @@ function renderServerActivity(report, audience) {
30340
30365
  const trendChart = sa.dailyTrend.length > 0 ? renderLineChart(
30341
30366
  sa.dailyTrend.map((d) => ({ x: d.date, y: d.verifiedCrawlerHits, label: d.date.slice(5) })),
30342
30367
  COLORS.series[1],
30343
- `Verified crawler hits over time (last ${windowDays2} days)`
30368
+ reportServerActivityTrendTitle(windowDays2)
30344
30369
  ) : "";
30370
+ const agency = copy.agency;
30371
+ const tiles = reportServerActivityAgencyTiles(windowDays2);
30372
+ const [operatorHeader, verifiedHeader, unverifiedHeader, userFetchesHeader, referralSessionsHeader, deltaHeader] = reportServerActivityAgencyOperatorHeaders(windowDays2);
30373
+ const [crawledPathHeader, hitsHeader, verifiedHitsHeader, operatorCountHeader] = agency.crawledPathHeaders;
30374
+ const [productHeader, productSessionsHeader, landingPathCountHeader] = agency.referralProductHeaders;
30375
+ const [landingPathHeader, landingSessionsHeader, productCountHeader] = agency.referralLandingHeaders;
30345
30376
  return section(
30346
- serverActivityHeading("agency", true, windowDays2),
30377
+ reportServerActivityHeading("agency", true, windowDays2),
30347
30378
  `<div class="metric-grid">
30348
30379
  <div class="metric">
30349
- <div class="label">Verified crawler hits (${windowLabel})</div>
30380
+ <div class="label">${tiles.verified}</div>
30350
30381
  <div class="value">${formatNumber(sa.verifiedCrawlerHits.current)}</div>
30351
- <div class="subtitle">${formatDelta(sa.verifiedCrawlerHits, "hits")}</div>
30382
+ <div class="subtitle">${formatDelta(sa.verifiedCrawlerHits, copy.countNouns.hits)}</div>
30352
30383
  </div>
30353
30384
  <div class="metric">
30354
- <div class="label">Unverified crawler hits (${windowLabel})</div>
30385
+ <div class="label">${tiles.unverified}</div>
30355
30386
  <div class="value">${formatNumber(sa.unverifiedCrawlerHits.current)}</div>
30356
- <div class="subtitle">${formatDelta(sa.unverifiedCrawlerHits, "hits")}</div>
30387
+ <div class="subtitle">${formatDelta(sa.unverifiedCrawlerHits, copy.countNouns.hits)}</div>
30357
30388
  </div>
30358
30389
  <div class="metric">
30359
- <div class="label">AI user-fetch hits (${windowLabel})</div>
30390
+ <div class="label">${tiles.userFetches}</div>
30360
30391
  <div class="value">${formatNumber(sa.aiUserFetchHits.current)}</div>
30361
- <div class="subtitle">${formatDelta(sa.aiUserFetchHits, "hits")}</div>
30392
+ <div class="subtitle">${formatDelta(sa.aiUserFetchHits, copy.countNouns.hits)}</div>
30362
30393
  </div>
30363
30394
  <div class="metric">
30364
- <div class="label">AI-referral sessions (${windowLabel})</div>
30395
+ <div class="label">${tiles.referralSessions}</div>
30365
30396
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
30366
30397
  <div class="subtitle">${referralSubtitle}</div>
30367
30398
  </div>
30368
30399
  </div>
30369
30400
  ${trendChart}
30370
- ${operatorRows ? `<div class="chart-card"><h3>Per AI operator</h3>
30371
- <p class="meta">Verified means the request's source IP falls inside the operator's published range. Unverified bots claim the user-agent but the IP is not in a published range, so it could be the real bot or an imitator. User fetches are on-demand reads from an AI surface on behalf of a real user (ChatGPT-User, Perplexity-User, \u2026), disjoint from bulk crawl and counted whether or not the IP can be verified.</p>
30372
- <table class="report-table">
30373
- <thead><tr><th>Operator</th><th class="numeric">Verified hits</th><th class="numeric">Unverified</th><th class="numeric">User fetches</th><th class="numeric">Referral sessions</th><th class="numeric">${windowLabel} delta</th></tr></thead>
30401
+ ${operatorRows ? `<div class="chart-card"><h3>${agency.operatorsHeading}</h3>
30402
+ <p class="meta">${agency.operatorsNote}</p>
30403
+ <div class="table-scroll"><table class="report-table">
30404
+ <thead><tr><th>${operatorHeader}</th><th class="numeric">${verifiedHeader}</th><th class="numeric">${unverifiedHeader}</th><th class="numeric">${userFetchesHeader}</th><th class="numeric">${referralSessionsHeader}</th><th class="numeric">${deltaHeader}</th></tr></thead>
30374
30405
  <tbody>${operatorRows}</tbody>
30375
- </table>
30406
+ </table></div>
30376
30407
  </div>` : ""}
30377
- ${pathRows ? `<div class="chart-card"><h3>Top crawled paths</h3>
30378
- <p class="meta">Pages AI bots fetched most often (verified only, last ${windowLabel}).</p>
30379
- <table class="report-table">
30380
- <thead><tr><th>Path</th><th class="numeric">Hits</th><th class="numeric">Verified</th><th class="numeric">Distinct operators</th></tr></thead>
30408
+ ${pathRows ? `<div class="chart-card"><h3>${agency.crawledPathsHeading}</h3>
30409
+ <p class="meta">${reportServerActivityCrawledPathsNote(windowDays2)}</p>
30410
+ <div class="table-scroll"><table class="report-table">
30411
+ <thead><tr><th>${crawledPathHeader}</th><th class="numeric">${hitsHeader}</th><th class="numeric">${verifiedHitsHeader}</th><th class="numeric">${operatorCountHeader}</th></tr></thead>
30381
30412
  <tbody>${pathRows}</tbody>
30382
- </table>
30413
+ </table></div>
30383
30414
  </div>` : ""}
30384
- ${referralProductRows ? `<div class="chart-card"><h3>AI-referral sessions by product</h3>
30385
- <p class="meta">Where humans landed coming from each AI product (chatgpt.com, claude.ai, \u2026).</p>
30386
- <table class="report-table">
30387
- <thead><tr><th>Product</th><th class="numeric">Sessions</th><th class="numeric">Distinct landing paths</th></tr></thead>
30415
+ ${referralProductRows ? `<div class="chart-card"><h3>${agency.referralProductsHeading}</h3>
30416
+ <p class="meta">${agency.referralProductsNote}</p>
30417
+ <div class="table-scroll"><table class="report-table">
30418
+ <thead><tr><th>${productHeader}</th><th class="numeric">${productSessionsHeader}</th><th class="numeric">${landingPathCountHeader}</th></tr></thead>
30388
30419
  <tbody>${referralProductRows}</tbody>
30389
- </table>
30420
+ </table></div>
30390
30421
  </div>` : ""}
30391
- ${referralLandingRows ? `<div class="chart-card"><h3>Top AI-referral landing paths</h3>
30392
- <table class="report-table">
30393
- <thead><tr><th>Path</th><th class="numeric">Sessions</th><th class="numeric">Distinct products</th></tr></thead>
30422
+ ${referralLandingRows ? `<div class="chart-card"><h3>${agency.referralLandingHeading}</h3>
30423
+ <div class="table-scroll"><table class="report-table">
30424
+ <thead><tr><th>${landingPathHeader}</th><th class="numeric">${landingSessionsHeader}</th><th class="numeric">${productCountHeader}</th></tr></thead>
30394
30425
  <tbody>${referralLandingRows}</tbody>
30395
- </table>
30426
+ </table></div>
30396
30427
  </div>` : ""}`
30397
30428
  );
30398
30429
  }
30399
30430
  function renderIndexingHealth(report) {
30400
30431
  const ih = report.indexingHealth;
30432
+ const copy = REPORT_SECTION_COPY["indexing-health"];
30401
30433
  if (!ih) {
30402
30434
  return section(
30403
- { id: "indexing-health", eyebrow: "Section 11", title: "Indexing Health" },
30404
- renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
30435
+ { id: ReportSectionIds["indexing-health"], eyebrow: copy.eyebrow, title: copy.title },
30436
+ renderEmpty(copy.empty)
30405
30437
  );
30406
30438
  }
30407
30439
  const segments = [
30408
- { label: "Indexed", count: ih.indexed, color: COLORS.positive },
30409
- { label: "Not indexed", count: ih.notIndexed, color: COLORS.caution },
30410
- { label: "Deindexed", count: ih.deindexed, color: COLORS.negative },
30411
- { label: "Unknown", count: ih.unknown, color: COLORS.neutral }
30440
+ { label: copy.segments.indexed, count: ih.indexed, color: COLORS.positive },
30441
+ { label: copy.segments.notIndexed, count: ih.notIndexed, color: COLORS.caution },
30442
+ { label: copy.segments.deindexed, count: ih.deindexed, color: COLORS.negative },
30443
+ { label: copy.segments.unknown, count: ih.unknown, color: COLORS.neutral }
30412
30444
  ].filter((s) => s.count > 0);
30413
30445
  const total = segments.reduce((s, x) => s + x.count, 0) || 1;
30414
30446
  const width = 600;
@@ -30420,166 +30452,171 @@ function renderIndexingHealth(report) {
30420
30452
  acc += w;
30421
30453
  return `<rect x="${x}" y="0" width="${w}" height="${height}" fill="${s.color}" />`;
30422
30454
  }).join("");
30423
- const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml2(s.label)}: ${s.count}</span>`).join("");
30455
+ const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml2(reportIndexingLegendLabel(s.label, s.count))}</span>`).join("");
30424
30456
  return section(
30425
- { id: "indexing-health", eyebrow: "Section 11", title: "Indexing Health", intro: `Pages absent from ${ih.provider === "google" ? "Google" : "Bing"} are harder for AI engines to retrieve.` },
30457
+ { id: ReportSectionIds["indexing-health"], eyebrow: copy.eyebrow, title: copy.title, intro: reportIndexingIntro(ih.provider) },
30426
30458
  `<div class="metric-grid">
30427
- <div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
30428
- <div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
30429
- <div class="metric"><div class="label">Indexed share</div><div class="value">${ih.indexedPct}%</div></div>
30459
+ <div class="metric"><div class="label">${copy.tiles.indexed}</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
30460
+ <div class="metric"><div class="label">${copy.tiles.total}</div><div class="value">${formatNumber(ih.total)}</div></div>
30461
+ <div class="metric"><div class="label">${copy.tiles.share}</div><div class="value">${ih.indexedPct}%</div></div>
30430
30462
  </div>
30431
30463
  <div class="chart-card">
30432
- <h3>Coverage breakdown</h3>
30433
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Coverage stacked bar">${bars}</svg>
30464
+ <h3>${copy.coverageHeading}</h3>
30465
+ <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${copy.coverageLabel}">${bars}</svg>
30434
30466
  <div class="legend">${legend}</div>
30435
30467
  </div>`
30436
30468
  );
30437
30469
  }
30438
30470
  function renderCitationsTrend(report) {
30439
30471
  const trend = report.citationsTrend;
30472
+ const copy = REPORT_SECTION_COPY["citations-trend"];
30440
30473
  if (trend.length === 0) {
30441
30474
  return section(
30442
- { id: "citations-trend", eyebrow: "Section 12", title: "Citations Over Time" },
30443
- renderEmpty("Run multiple checks to see a trend.")
30475
+ { id: ReportSectionIds["citations-trend"], eyebrow: copy.eyebrow, title: copy.title },
30476
+ renderEmpty(copy.empty)
30444
30477
  );
30445
30478
  }
30446
30479
  if (isTrendBaseline(trend)) {
30447
30480
  return section(
30448
- { id: "citations-trend", eyebrow: "Section 12", title: "Citations Over Time" },
30449
- renderEmpty(`Building baseline (${trend.length} of ${MIN_TREND_POINTS} checks completed). Trend will appear once more checks are recorded.`)
30481
+ { id: ReportSectionIds["citations-trend"], eyebrow: copy.eyebrow, title: copy.title },
30482
+ renderEmpty(reportCitationsTrendBaseline(trend.length))
30450
30483
  );
30451
30484
  }
30452
30485
  const chart = renderLineChart(
30453
30486
  trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
30454
30487
  COLORS.positive,
30455
- "Overall citation rate",
30488
+ copy.chartTitle,
30456
30489
  220
30457
30490
  );
30458
30491
  const rows = trend.map((t) => `
30459
30492
  <tr>
30460
30493
  <td>${formatDate(t.date)}</td>
30461
30494
  <td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
30462
- <td>${t.providerRates.map((r) => `${escapeHtml2(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
30495
+ <td>${escapeHtml2(reportTrendProviderRates(t.providerRates))}</td>
30463
30496
  </tr>`).join("");
30497
+ const [checkHeader, citedQueriesHeader, engineRatesHeader] = copy.breakdownHeaders;
30464
30498
  return section(
30465
- { id: "citations-trend", eyebrow: "Section 12", title: "Citations Over Time", intro: "Citation coverage across recent checks." },
30499
+ { id: ReportSectionIds["citations-trend"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30466
30500
  `${chart}
30467
- <div class="chart-card"><h3>Check-by-check breakdown</h3>
30468
- <table class="report-table">
30469
- <thead><tr><th>Check</th><th class="numeric">Cited queries</th><th>Per-engine rates</th></tr></thead>
30501
+ <div class="chart-card"><h3>${copy.breakdownHeading}</h3>
30502
+ <div class="table-scroll"><table class="report-table">
30503
+ <thead><tr><th>${checkHeader}</th><th class="numeric">${citedQueriesHeader}</th><th>${engineRatesHeader}</th></tr></thead>
30470
30504
  <tbody>${rows}</tbody>
30471
- </table>
30505
+ </table></div>
30472
30506
  </div>`
30473
30507
  );
30474
30508
  }
30475
30509
  function renderInsights(report) {
30476
30510
  const list = report.insights;
30511
+ const copy = REPORT_SECTION_COPY.insights;
30477
30512
  if (list.length === 0) {
30478
30513
  return section(
30479
- { id: "insights", eyebrow: "Section 13", title: "Insights & Alerts" },
30480
- renderEmpty("No insights yet \u2014 run a check to generate alerts.")
30514
+ { id: ReportSectionIds.insights, eyebrow: copy.eyebrow, title: copy.title },
30515
+ renderEmpty(copy.empty)
30481
30516
  );
30482
30517
  }
30483
30518
  const haveDeduped = list.every((i) => typeof i.instanceCount === "number");
30484
30519
  const rows = (haveDeduped ? list.map((i) => ({ rep: i, count: i.instanceCount })) : groupInsights(list).map((g) => ({ rep: g.representative, count: g.count }))).map(({ rep: i, count: count4 }) => {
30485
- const tone = severityTone(i.severity);
30486
- const countChip = count4 > 1 ? ` <span class="badge tone-neutral">\xD7 ${count4}</span>` : "";
30520
+ const tone = reportInsightTone(i);
30521
+ const countChip = count4 > 1 ? ` <span class="badge tone-neutral">${reportInstanceCountLabel(count4)}</span>` : "";
30487
30522
  return `<tr>
30488
30523
  <td class="col-severity"><span class="badge tone-${tone}">${escapeHtml2(reportSeverityLabel(i.severity))}</span></td>
30489
30524
  <td class="col-title">${escapeHtml2(i.title)}${countChip}</td>
30490
30525
  <td class="col-query">${escapeHtml2(i.query)}</td>
30491
30526
  <td class="col-provider">${escapeHtml2(i.provider)}</td>
30492
- <td class="col-recommendation">${i.recommendation ? escapeHtml2(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
30527
+ <td class="col-recommendation">${i.recommendation ? escapeHtml2(i.recommendation) : `<span class="cell-pending">${copy.noRecommendation}</span>`}</td>
30493
30528
  </tr>`;
30494
30529
  }).join("");
30530
+ const [severityHeader, titleHeader, queryHeader, providerHeader, recommendationHeader] = copy.headers;
30495
30531
  return section(
30496
- { id: "insights", eyebrow: "Section 13", title: "Insights & Alerts", intro: "Regressions, gains, and recurring alerts ordered by severity." },
30497
- `<table class="report-table insights-table">
30532
+ { id: ReportSectionIds.insights, eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30533
+ `<div class="table-scroll"><table class="report-table insights-table">
30498
30534
  <thead><tr>
30499
- <th class="col-severity">Severity</th>
30500
- <th class="col-title">Title</th>
30501
- <th class="col-query">Query</th>
30502
- <th class="col-provider">Provider</th>
30503
- <th class="col-recommendation">Recommendation</th>
30535
+ <th class="col-severity">${severityHeader}</th>
30536
+ <th class="col-title">${titleHeader}</th>
30537
+ <th class="col-query">${queryHeader}</th>
30538
+ <th class="col-provider">${providerHeader}</th>
30539
+ <th class="col-recommendation">${recommendationHeader}</th>
30504
30540
  </tr></thead>
30505
30541
  <tbody>${rows}</tbody>
30506
- </table>`
30542
+ </table></div>`
30507
30543
  );
30508
30544
  }
30509
30545
  function renderOpportunities(report) {
30510
30546
  const opps = dedupeReportOpportunities(report);
30511
30547
  if (opps.length === 0) return "";
30512
30548
  const canonical = report.meta.project.canonicalDomain;
30549
+ const copy = REPORT_SECTION_COPY["content-opportunities"];
30513
30550
  const highlights = `<div class="opportunity-grid">
30514
30551
  ${opps.slice(0, 3).map((o) => `<article class="opportunity-card">
30515
- <div class="opportunity-score" title="Opportunity score (0\u2013100, higher = stronger)">${Math.round(o.score)}<span class="opportunity-score-suffix">/100</span></div>
30552
+ <div class="opportunity-score" title="${copy.scoreCardTooltip}">${Math.round(o.score)}<span class="opportunity-score-suffix">${copy.scoreSuffix}</span></div>
30516
30553
  <h3>${escapeHtml2(o.query)}</h3>
30517
- <p>${escapeHtml2(contentActionLabel(o.action))} \xB7 ${escapeHtml2(actionConfidenceLabel(o.actionConfidence))} confidence</p>
30554
+ <p>${escapeHtml2(reportOpportunityActionLine(o))}</p>
30518
30555
  ${renderProofChips(o.drivers, 2)}
30519
30556
  </article>`).join("")}
30520
30557
  </div>`;
30521
30558
  const rows = opps.slice(0, 10).map((o) => {
30522
- const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
30523
- const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
30524
- const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml2(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
30559
+ const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.ourBestPage.url)}</a>` : `<span class="cell-not-cited">${copy.noPage}</span>`;
30560
+ const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.winningCompetitor.domain)}</a>` : `<span class="cell-not-cited">${copy.noWinningCompetitor}</span>`;
30561
+ const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml2(d)}</li>`).join("")}</ul>` : `<span class="cell-not-cited">${copy.noDriverSignal}</span>`;
30525
30562
  const surfaceTone = o.winnabilityClass === "ceded" ? "tone-caution" : "tone-neutral";
30526
30563
  return `<tr>
30527
30564
  <td>${escapeHtml2(o.query)}</td>
30528
30565
  <td><span class="badge tone-neutral">${escapeHtml2(contentActionLabel(o.action))}</span></td>
30529
30566
  <td><span class="badge ${surfaceTone}">${escapeHtml2(winnabilityClassLabel(o.winnabilityClass))}</span></td>
30530
- <td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
30567
+ <td class="numeric" title="${copy.scoreHeaderTooltip}">${Math.round(o.score)}</td>
30531
30568
  <td>${drivers}</td>
30532
30569
  <td>${ourPage}</td>
30533
30570
  <td>${winning}</td>
30534
30571
  <td><span class="badge tone-neutral">${escapeHtml2(actionConfidenceLabel(o.actionConfidence))}</span></td>
30535
30572
  </tr>`;
30536
30573
  }).join("");
30574
+ const [queryHeader, actionHeader, winnabilityHeader, scoreHeader, whyHeader, ourPageHeader, winningHeader, confidenceHeader] = copy.headers;
30537
30575
  return section(
30538
30576
  {
30539
- id: "content-opportunities",
30540
- eyebrow: "Section 14",
30541
- title: "Content Opportunities",
30542
- intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger. Winnability flags whether the cited surface is ownable or ceded to aggregators/editorial."
30577
+ id: ReportSectionIds["content-opportunities"],
30578
+ eyebrow: copy.eyebrow,
30579
+ title: copy.title,
30580
+ intro: copy.intro
30543
30581
  },
30544
- `${highlights}<table class="report-table">
30545
- <thead><tr><th>Query</th><th>Action</th><th>Winnability</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
30582
+ `${highlights}<div class="table-scroll"><table class="report-table">
30583
+ <thead><tr><th>${queryHeader}</th><th>${actionHeader}</th><th>${winnabilityHeader}</th><th class="numeric" title="${copy.scoreHeaderTooltip}">${scoreHeader}</th><th>${whyHeader}</th><th>${ourPageHeader}</th><th>${winningHeader}</th><th>${confidenceHeader}</th></tr></thead>
30546
30584
  <tbody>${rows}</tbody>
30547
- </table>`
30585
+ </table></div>`
30548
30586
  );
30549
30587
  }
30550
30588
  function renderContentGaps(report) {
30551
30589
  const gaps = report.contentGaps;
30552
30590
  if (gaps.length === 0) return "";
30591
+ const copy = REPORT_SECTION_COPY["content-gaps"];
30553
30592
  const rows = gaps.slice(0, 10).map((g) => {
30554
- const competitorList = g.competitorDomains.slice(0, 5).map(escapeHtml2).join(", ");
30555
- const more = g.competitorDomains.length > 5 ? `, +${g.competitorDomains.length - 5} more` : "";
30556
30593
  return `<tr>
30557
30594
  <td>${escapeHtml2(g.query)}</td>
30558
30595
  <td class="numeric">${g.competitorCount}</td>
30559
- <td>${competitorList}${more}</td>
30560
- <td class="numeric">${Math.round(g.missRate * 100)}%</td>
30596
+ <td>${escapeHtml2(reportCompactList(g.competitorDomains, 5))}</td>
30597
+ <td class="numeric">${reportMissRateLabel(g.missRate)}</td>
30561
30598
  </tr>`;
30562
30599
  }).join("");
30600
+ const [queryHeader, competitorsHeader, domainsHeader, missRateHeader] = copy.headers;
30563
30601
  return section(
30564
30602
  {
30565
- id: "content-gaps",
30566
- eyebrow: "Section 15",
30567
- title: "Content Gaps",
30568
- intro: "Tracked queries where competitors are cited and the client is missing."
30603
+ id: ReportSectionIds["content-gaps"],
30604
+ eyebrow: copy.eyebrow,
30605
+ title: copy.title,
30606
+ intro: copy.intro
30569
30607
  },
30570
- `<table class="report-table">
30571
- <thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
30608
+ `<div class="table-scroll"><table class="report-table">
30609
+ <thead><tr><th>${queryHeader}</th><th class="numeric">${competitorsHeader}</th><th>${domainsHeader}</th><th class="numeric">${missRateHeader}</th></tr></thead>
30572
30610
  <tbody>${rows}</tbody>
30573
- </table>`
30611
+ </table></div>`
30574
30612
  );
30575
30613
  }
30576
30614
  function renderRecommendedNextSteps(report) {
30577
30615
  const steps = report.recommendedNextSteps;
30616
+ const copy = REPORT_SECTION_COPY["recommended-next-steps"];
30617
+ const heading = { id: ReportSectionIds["recommended-next-steps"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro };
30578
30618
  if (steps.length === 0) {
30579
- return section(
30580
- { id: "recommended-next-steps", eyebrow: "Section 16", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
30581
- renderEmpty("No outstanding actions.")
30582
- );
30619
+ return section(heading, renderEmpty(copy.empty));
30583
30620
  }
30584
30621
  const items = steps.map((s) => `
30585
30622
  <div class="step">
@@ -30587,17 +30624,12 @@ function renderRecommendedNextSteps(report) {
30587
30624
  <span class="title">${escapeHtml2(s.title)}</span>
30588
30625
  <span class="rationale">${escapeHtml2(s.rationale)}</span>
30589
30626
  </div>`).join("");
30590
- return section(
30591
- { id: "recommended-next-steps", eyebrow: "Section 16", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
30592
- `<div class="steps">${items}</div>`
30593
- );
30594
- }
30595
- function actionAudienceMatches(action, audience) {
30596
- return action.audience === "both" || action.audience === audience;
30627
+ return section(heading, `<div class="steps">${items}</div>`);
30597
30628
  }
30598
30629
  function renderActionCards(actions, audience) {
30599
30630
  const isClient = audience === "client";
30600
- if (actions.length === 0) return renderEmpty(isClient ? "No recommendations yet \u2014 run an AI check to populate this." : "No prioritized actions yet.");
30631
+ const copy = isClient ? REPORT_SECTION_COPY["client-action-plan"] : REPORT_SECTION_COPY["agency-action-plan"];
30632
+ if (actions.length === 0) return renderEmpty(copy.empty);
30601
30633
  return `<div class="action-card-grid">
30602
30634
  ${actions.map((action, idx) => {
30603
30635
  const tone = reportActionTone(action);
@@ -30605,17 +30637,17 @@ function renderActionCards(actions, audience) {
30605
30637
  const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item2) => `<li>${escapeHtml2(item2)}</li>`).join("")}</ul>` : "";
30606
30638
  const proof = renderProofChips(action.evidence.length > 0 ? action.evidence : action.why, 3);
30607
30639
  const details = why || evidence ? `<details class="action-details">
30608
- <summary>${isClient ? "See the data behind this" : "Evidence details"}</summary>
30609
- ${why ? `<div><strong>${isClient ? "Why this matters" : "Why"}</strong>${why}</div>` : ""}
30610
- ${evidence ? `<div><strong>${isClient ? "What we saw" : "Evidence"}</strong>${evidence}</div>` : ""}
30640
+ <summary>${copy.detailsSummary}</summary>
30641
+ ${why ? `<div><strong>${copy.whyLabel}</strong>${why}</div>` : ""}
30642
+ ${evidence ? `<div><strong>${copy.evidenceLabel}</strong>${evidence}</div>` : ""}
30611
30643
  </details>` : "";
30612
- const horizonLabel = isClient ? clientHorizonLabel(action.horizon) : reportHorizonLabel(action.horizon);
30613
- const confidenceLabel = isClient ? clientConfidenceLabel(action.confidence) : `${reportConfidenceLabel(action.confidence)} confidence`;
30644
+ const horizonLabel = reportActionHorizonBadge(audience, action.horizon);
30645
+ const confidenceLabel = reportActionConfidenceBadge(audience, action.confidence);
30614
30646
  const categoryBadge = isClient ? "" : `<span class="badge tone-neutral">${escapeHtml2(reportActionCategoryLabel(action.category))}</span>`;
30615
- const successLabel = isClient ? "What success looks like:" : "Win condition:";
30647
+ const successLabel = copy.successLabel;
30616
30648
  return `<article class="action-card">
30617
30649
  <div class="action-head">
30618
- <div class="action-rank" title="${isClient ? "Priority \u2014 1 will move the needle fastest" : "Impact rank \u2014 1 is the highest-leverage action"}">${idx + 1}</div>
30650
+ <div class="action-rank" title="${copy.rankTitle}">${idx + 1}</div>
30619
30651
  <div>
30620
30652
  <div class="action-meta">
30621
30653
  <span class="badge tone-${tone}">${escapeHtml2(horizonLabel)}</span>
@@ -30634,71 +30666,68 @@ function renderActionCards(actions, audience) {
30634
30666
  </div>`;
30635
30667
  }
30636
30668
  function renderAudienceActionPlan(report, audience) {
30637
- const rawActions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
30638
- const actions = dedupeReportActions(report, rawActions);
30669
+ const id = audience === "client" ? ReportSectionIds["client-action-plan"] : ReportSectionIds["agency-action-plan"];
30670
+ const copy = REPORT_SECTION_COPY[id];
30639
30671
  return section(
30640
- {
30641
- id: audience === "client" ? "client-action-plan" : "agency-action-plan",
30642
- eyebrow: audience === "client" ? "Action plan" : "Agency actions",
30643
- title: audience === "client" ? "What to do next" : "Agency Action Plan",
30644
- intro: audience === "client" ? "Approve these in order. They are sorted by what will move the needle fastest." : "The highest-leverage work, sorted by urgency and evidence strength."
30645
- },
30646
- renderActionCards(actions, audience)
30672
+ { id, eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30673
+ renderActionCards(reportAudienceActions(report, audience), audience)
30647
30674
  );
30648
30675
  }
30649
30676
  function renderClientSummary(report) {
30650
30677
  const s = report.executiveSummary;
30651
30678
  const sc = report.citationScorecard;
30679
+ const copy = REPORT_SECTION_COPY["client-summary"];
30652
30680
  const totalQ = s.totalQueryCount ?? 0;
30653
30681
  const heroNumber = totalQ > 0 ? `${s.mentionRate}%` : "\u2014";
30654
- const heroSentence = totalQ > 0 ? `When customers asked AI ${totalQ} ${pluralize(totalQ, "query", "queries")} about your industry, AI mentioned you in ${s.mentionedQueryCount} of ${totalQ === 1 ? "them" : "those queries"}.` : "No AI check has been run yet. Run a check to see how AI tools answer customer queries about your business.";
30655
- const trend = clientTrendCopy(report.whatsChanged.mentionRate);
30682
+ const heroSentence = reportClientHeroSentence(totalQ, s.mentionedQueryCount);
30683
+ const trend = reportClientTrendCopy(report.whatsChanged.mentionRate);
30656
30684
  const heroTrend = trend ? `<p class="client-hero-trend tone-${trend.tone}"><span style="margin-right:6px;">${trend.arrow}</span>${escapeHtml2(trend.text)}</p>` : "";
30657
30685
  const hero = `<div class="client-hero">
30658
- <div class="client-hero-eyebrow">Overview</div>
30686
+ <div class="client-hero-eyebrow">${copy.heroEyebrow}</div>
30659
30687
  <div class="client-hero-number">${heroNumber}</div>
30660
30688
  <p class="client-hero-sentence">${escapeHtml2(heroSentence)}</p>
30661
30689
  ${heroTrend}
30662
30690
  </div>`;
30663
- const providerSubtitle = sc.providers.length > 0 ? sc.providers.map(providerDisplayName).join(", ") : `${formatNumber(s.queryCount)} ${pluralize(s.queryCount, "query", "queries")} tested`;
30691
+ const providerSubtitle = reportClientProvidersSubtitle(sc.providers, s.queryCount);
30664
30692
  const tiles = `<div class="client-metric-grid">
30665
30693
  <div class="client-metric-tile">
30666
- <div class="label">AI mentions your name</div>
30694
+ <div class="label">${copy.tiles.mentioned}</div>
30667
30695
  <div class="value">${s.mentionRate}%</div>
30668
- <div class="subtitle">${totalQ > 0 ? `Says your name in ${s.mentionedQueryCount} of ${totalQ} ${pluralize(totalQ, "query", "queries")}` : "No data yet"}</div>
30696
+ <div class="subtitle">${reportClientMentionedSubtitle(s.mentionedQueryCount, totalQ)}</div>
30669
30697
  </div>
30670
30698
  <div class="client-metric-tile">
30671
- <div class="label">AI links to your website</div>
30699
+ <div class="label">${copy.tiles.cited}</div>
30672
30700
  <div class="value">${s.citationRate}%</div>
30673
- <div class="subtitle">${totalQ > 0 ? `Cites your site as a source in ${s.citedQueryCount} of ${totalQ} ${pluralize(totalQ, "query", "queries")}` : "No data yet"}</div>
30701
+ <div class="subtitle">${reportClientCitedSubtitle(s.citedQueryCount, totalQ)}</div>
30674
30702
  </div>
30675
30703
  <div class="client-metric-tile">
30676
- <div class="label">AI tools tested</div>
30704
+ <div class="label">${copy.tiles.providers}</div>
30677
30705
  <div class="value">${formatNumber(s.providerCount)}</div>
30678
30706
  <div class="subtitle">${escapeHtml2(providerSubtitle)}</div>
30679
30707
  </div>
30680
30708
  </div>`;
30709
+ const { lead, mention, link, closing } = copy.explainer;
30681
30710
  const explainer = `<div class="client-explainer">
30682
- <strong>Mentions and links are different.</strong>
30683
- A <span class="term">mention</span> is when AI says your name out loud in its answer.
30684
- A <span class="term">link</span> is when AI lists your website as a source it used.
30685
- AI can do either, both, or neither \u2014 that's why we track both.
30711
+ <strong>${lead}</strong>
30712
+ ${mention.article} <span class="term">${mention.term}</span> ${mention.definition}
30713
+ ${link.article} <span class="term">${link.term}</span> ${link.definition}
30714
+ ${closing}
30686
30715
  </div>`;
30687
30716
  const questions = sc.queries.length > 0 ? `<div class="client-card">
30688
- <h3>Customer queries we tested</h3>
30689
- <p class="card-subtitle">These are the ${sc.queries.length} ${pluralize(sc.queries.length, "query we asked", "queries we asked")} every AI tool. The numbers above measure how often you came up.</p>
30717
+ <h3>${copy.queriesHeading}</h3>
30718
+ <p class="card-subtitle">${reportClientQueriesSubtitle(sc.queries.length)}</p>
30690
30719
  <ol class="client-questions-list">
30691
30720
  ${sc.queries.map((q, i) => `<li><span class="qnum">${String(i + 1).padStart(2, "0")}</span><span>"${escapeHtml2(q)}"</span></li>`).join("")}
30692
30721
  </ol>
30693
30722
  </div>` : "";
30694
30723
  const providerBars = sc.providerRates.length > 0 ? `<div class="client-card">
30695
- <h3>How often each AI tool mentions you</h3>
30696
- <p class="card-subtitle">Higher is better. Each bar shows the share of customer queries where the AI named you in the answer.</p>
30724
+ <h3>${copy.providerBarsHeading}</h3>
30725
+ <p class="card-subtitle">${copy.providerBarsSubtitle}</p>
30697
30726
  <div class="client-bar-list">
30698
30727
  ${sc.providerRates.map((r) => {
30699
30728
  const pct = Math.max(r.mentionRate, 1.5);
30700
30729
  return `<div class="client-bar-row">
30701
- <span class="bar-label">${escapeHtml2(providerDisplayName(r.provider))}</span>
30730
+ <span class="bar-label">${escapeHtml2(reportProviderDisplayName(r.provider))}</span>
30702
30731
  <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
30703
30732
  <span class="bar-value">${r.mentionRate}% <span class="bar-value-sub">(${r.mentionedCount}/${r.totalCount})</span></span>
30704
30733
  </div>`;
@@ -30715,34 +30744,35 @@ function renderClientEvidenceSummary(report) {
30715
30744
  const opportunities = dedupeReportOpportunities(report).slice(0, 5);
30716
30745
  const aiMax = ai.length > 0 ? Math.max(...ai.map((d) => d.count)) : 0;
30717
30746
  const gscMax = gsc ? Math.max(...gsc.topQueries.slice(0, 5).map((q) => q.impressions), 1) : 0;
30747
+ const copy = REPORT_SECTION_COPY["client-evidence-summary"];
30718
30748
  const cards = [];
30719
30749
  if (ai.length > 0) {
30720
30750
  cards.push(`<div class="client-card">
30721
- <h3>Where AI gets its answers</h3>
30722
- <p class="card-subtitle">The websites AI tools cited most often when answering customer queries about your industry.</p>
30751
+ <h3>${copy.sources.heading}</h3>
30752
+ <p class="card-subtitle">${copy.sources.subtitle}</p>
30723
30753
  <div class="client-bar-list">
30724
30754
  ${ai.map((d) => {
30725
30755
  const pct = aiMax > 0 ? Math.max(d.count / aiMax * 100, 1.5) : 0;
30726
- const label = escapeHtml2(d.domain) + (d.isCompetitor ? ' <span style="color:' + COLORS.textFaint + ';font-size:11px;">(competitor)</span>' : "");
30756
+ const label = escapeHtml2(d.domain) + (d.isCompetitor ? ` <span style="color:${COLORS.textFaint};font-size:11px;">${copy.sources.competitorTag}</span>` : "");
30727
30757
  return `<div class="client-bar-row">
30728
30758
  <span class="bar-label">${label}</span>
30729
30759
  <div class="bar-track"><div class="bar-fill bar-fill-neutral" style="width:${pct}%"></div></div>
30730
- <span class="bar-value">${formatNumber(d.count)}\xD7</span>
30760
+ <span class="bar-value">${reportClientSourceCount(d.count)}</span>
30731
30761
  </div>`;
30732
30762
  }).join("")}
30733
30763
  </div>
30734
30764
  </div>`);
30735
30765
  }
30736
30766
  if (indexing) {
30737
- const tone = indexing.indexedPct >= 90 ? "positive" : indexing.indexedPct >= 70 ? "caution" : "negative";
30767
+ const tone = reportClientIndexingTone(indexing.indexedPct);
30738
30768
  const fillPct = Math.max(indexing.indexedPct, 1.5);
30739
30769
  cards.push(`<div class="client-card">
30740
- <h3>Pages Google can find on your site</h3>
30741
- <p class="card-subtitle">Google indexing your site increases the chances of it appearing in AI search (especially Gemini).</p>
30770
+ <h3>${copy.indexing.heading}</h3>
30771
+ <p class="card-subtitle">${copy.indexing.subtitle}</p>
30742
30772
  <div class="client-progress-number tone-${tone}">${indexing.indexedPct}%</div>
30743
- <div style="font-size:12px;color:${COLORS.textMuted};">${formatNumber(indexing.indexed)} of ${formatNumber(indexing.total)} pages indexed</div>
30773
+ <div style="font-size:12px;color:${COLORS.textMuted};">${reportClientIndexedPages(indexing.indexed, indexing.total)}</div>
30744
30774
  <div class="client-progress-bar"><div class="client-progress-fill tone-${tone}" style="width:${fillPct}%"></div></div>
30745
- <p style="margin:0;font-size:12px;color:${COLORS.textMuted};"><strong style="color:${COLORS.text};">${formatNumber(indexing.notIndexed)}</strong> ${pluralize(indexing.notIndexed, "page is", "pages are")} not indexed yet.</p>
30775
+ <p style="margin:0;font-size:12px;color:${COLORS.textMuted};"><strong style="color:${COLORS.text};">${formatNumber(indexing.notIndexed)}</strong> ${reportClientNotIndexedTail(indexing.notIndexed)}</p>
30746
30776
  </div>`);
30747
30777
  }
30748
30778
  if (gsc) {
@@ -30753,53 +30783,54 @@ function renderClientEvidenceSummary(report) {
30753
30783
  return `<div class="client-bar-row">
30754
30784
  <span class="bar-label">${escapeHtml2(q.query)}</span>
30755
30785
  <div class="bar-track"><div class="bar-fill bar-fill-sky" style="width:${pct}%"></div></div>
30756
- <span class="bar-value">${formatNumber(q.impressions)} ${pluralize(q.impressions, "search", "searches")}</span>
30786
+ <span class="bar-value">${reportClientSearchCount(q.impressions)}</span>
30757
30787
  </div>`;
30758
30788
  }).join("")}
30759
30789
  </div>` : "";
30760
30790
  cards.push(`<div class="client-card">
30761
- <h3>What people search Google for</h3>
30762
- <p class="card-subtitle">You appeared in <strong style="color:${COLORS.text};">${formatNumber(gsc.totalImpressions)}</strong> Google searches and got <strong style="color:${COLORS.text};">${formatNumber(gsc.totalClicks)}</strong> ${pluralize(gsc.totalClicks, "click")} this period.</p>
30791
+ <h3>${copy.search.heading}</h3>
30792
+ <p class="card-subtitle">${copy.search.subtitleLead} <strong style="color:${COLORS.text};">${formatNumber(gsc.totalImpressions)}</strong> ${copy.search.subtitleMiddle} <strong style="color:${COLORS.text};">${formatNumber(gsc.totalClicks)}</strong> ${reportClientClicksNoun(gsc.totalClicks)} ${copy.search.subtitleTail}</p>
30763
30793
  ${queryRows2}
30764
30794
  </div>`);
30765
30795
  }
30766
30796
  if (opportunities.length > 0) {
30767
30797
  cards.push(`<div class="client-card">
30768
- <h3>Topics where you could improve</h3>
30769
- <p class="card-subtitle">Customer queries where better content on your site would help AI cite you.</p>
30798
+ <h3>${copy.opportunities.heading}</h3>
30799
+ <p class="card-subtitle">${copy.opportunities.subtitle}</p>
30770
30800
  <ul class="client-opportunity-list">
30771
30801
  ${opportunities.map((o) => `<li>
30772
30802
  <div class="op-query">${escapeHtml2(o.query)}</div>
30773
- <div class="op-action">${escapeHtml2(contentActionLabel(o.action))}${o.winnabilityClass === "ceded" ? ' <span class="badge tone-caution">Ceded surface</span>' : ""}</div>
30803
+ <div class="op-action">${escapeHtml2(contentActionLabel(o.action))}${o.winnabilityClass === "ceded" ? ` <span class="badge tone-caution">${copy.opportunities.cededTag}</span>` : ""}</div>
30774
30804
  </li>`).join("")}
30775
30805
  </ul>
30776
30806
  </div>`);
30777
30807
  }
30778
30808
  return section(
30779
30809
  {
30780
- id: "client-evidence-summary",
30781
- eyebrow: "What we based this on",
30782
- title: "The signals behind this plan",
30783
- intro: "The data behind the recommendations above. Switch to Agency for the full breakdowns."
30810
+ id: ReportSectionIds["client-evidence-summary"],
30811
+ eyebrow: copy.eyebrow,
30812
+ title: copy.title,
30813
+ intro: copy.intro
30784
30814
  },
30785
- cards.length > 0 ? `<div class="client-evidence-grid">${cards.join("")}</div>` : renderEmpty("No supporting evidence yet \u2014 this fills in after the first AI check.")
30815
+ cards.length > 0 ? `<div class="client-evidence-grid">${cards.join("")}</div>` : renderEmpty(copy.empty)
30786
30816
  );
30787
30817
  }
30788
30818
  function renderAgencyDiagnostics(report) {
30789
- const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== "Location caveat");
30819
+ const copy = REPORT_SECTION_COPY["agency-diagnostics"];
30820
+ const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== copy.hiddenTitle);
30790
30821
  const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
30791
30822
  ${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
30792
30823
  <h3>${escapeHtml2(d.title)}</h3>
30793
30824
  <p>${escapeHtml2(d.detail)}</p>
30794
30825
  ${renderProofChips(d.evidence, 3)}
30795
30826
  </div>`).join("")}
30796
- </div>` : renderEmpty("No agency diagnostics available yet.");
30827
+ </div>` : renderEmpty(copy.empty);
30797
30828
  return section(
30798
30829
  {
30799
- id: "agency-diagnostics",
30800
- eyebrow: "Agency diagnostics",
30801
- title: "Technical Diagnostics",
30802
- intro: "Fast-read operator flags behind the action plan."
30830
+ id: ReportSectionIds["agency-diagnostics"],
30831
+ eyebrow: copy.eyebrow,
30832
+ title: copy.title,
30833
+ intro: copy.intro
30803
30834
  },
30804
30835
  body
30805
30836
  );
@@ -30855,9 +30886,9 @@ function renderReportHtml(report, opts = {}) {
30855
30886
  <body>
30856
30887
  <div class="container">
30857
30888
  <header class="header">
30858
- <div class="eyebrow">AI Visibility Report</div>
30889
+ <div class="eyebrow">${REPORT_HEADER_COPY.eyebrow}</div>
30859
30890
  <h1>${escapeHtml2(report.meta.project.displayName)}</h1>
30860
- <div class="subtitle">${escapeHtml2(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml2(report.meta.project.country)} / ${escapeHtml2(report.meta.project.language.toUpperCase())}${report.visibility?.selection.mode === "advanced" ? ` \xB7 ${escapeHtml2(reportVisibilityLocationLabel(report.visibility))}` : renderHeaderLocationFragment(report.meta.location)} \xB7 Last ${report.meta.periodDays} days \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
30891
+ <div class="subtitle">${escapeHtml2(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml2(report.meta.project.country)} / ${escapeHtml2(report.meta.project.language.toUpperCase())}${report.visibility?.selection.mode === "advanced" ? ` \xB7 ${escapeHtml2(reportVisibilityLocationLabel(report.visibility))}` : renderHeaderLocationFragment(report.meta.location)} \xB7 ${reportHeaderPeriodLabel(report.meta.periodDays)} \xB7 ${REPORT_HEADER_COPY.generated} ${formatDate(report.meta.generatedAt)}</div>
30861
30892
  </header>
30862
30893
  ${sections}
30863
30894
  <footer class="footer">Generated by <a href="https://canonry.ai">canonry</a> \xB7 ${escapeHtml2(formatIsoDate(report.meta.generatedAt))}</footer>
@@ -30871,8 +30902,8 @@ function renderReportVisibility(visibility) {
30871
30902
  const historyPopulations = visibility.populations.filter((population) => population.queryClass !== "unknown" || population.trend.some((point) => point.answerCount > 0));
30872
30903
  const rateCell = (rate2) => `<td><strong>${escapeHtml2(reportVisibilityRate(rate2))}</strong><p class="muted">${escapeHtml2(reportVisibilityEvidence(rate2))}</p></td>`;
30873
30904
  const headers = (labels) => `<thead><tr>${labels.map((label) => `<th>${escapeHtml2(label)}</th>`).join("")}</tr></thead>`;
30874
- const summary2 = `<table>${headers([REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.queries, REPORT_VISIBILITY_COPY.answers, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited])}<tbody>${populations.map((population) => `<tr><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td><td>${population.summary.queryCount}</td><td>${population.summary.answerCount}</td>${rateCell(population.summary.mentionCoverage)}${rateCell(population.summary.citationCoverage)}</tr>`).join("")}</tbody></table>`;
30875
- const trend = `<details><summary>${escapeHtml2(reportVisibilityHistoryLabel(visibility))}</summary><div class="table-scroll"><table>${headers([REPORT_VISIBILITY_COPY.date, REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited, REPORT_VISIBILITY_COPY.comparison])}<tbody>${historyPopulations.flatMap((population) => population.trend.map((point) => `<tr><td><time datetime="${escapeHtml2(point.createdAt)}">${escapeHtml2(point.createdAt.slice(0, 10))}</time></td><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td>${rateCell(point.mentionCoverage)}${rateCell(point.citationCoverage)}<td>${escapeHtml2(reportVisibilityComparison(point.continuity.state, point.continuity.comparedRunId !== null && !population.trend.some((previous) => previous.runId === point.continuity.comparedRunId)))}</td></tr>`)).join("")}</tbody></table></div></details>`;
30905
+ const summary2 = `<table class="report-table">${headers([REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.queries, REPORT_VISIBILITY_COPY.answers, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited])}<tbody>${populations.map((population) => `<tr><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td><td>${population.summary.queryCount}</td><td>${population.summary.answerCount}</td>${rateCell(population.summary.mentionCoverage)}${rateCell(population.summary.citationCoverage)}</tr>`).join("")}</tbody></table>`;
30906
+ const trend = `<details><summary>${escapeHtml2(reportVisibilityHistoryLabel(visibility))}</summary><div class="table-scroll"><table class="report-table">${headers([REPORT_VISIBILITY_COPY.date, REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited, REPORT_VISIBILITY_COPY.comparison])}<tbody>${historyPopulations.flatMap((population) => population.trend.map((point) => `<tr><td><time datetime="${escapeHtml2(point.createdAt)}">${escapeHtml2(point.createdAt.slice(0, 10))}</time></td><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td>${rateCell(point.mentionCoverage)}${rateCell(point.citationCoverage)}<td>${escapeHtml2(reportVisibilityComparison(point.continuity.state, point.continuity.comparedRunId !== null && !population.trend.some((previous) => previous.runId === point.continuity.comparedRunId)))}</td></tr>`)).join("")}</tbody></table></div></details>`;
30876
30907
  return `<section id="client-summary" class="report-section" aria-label="${escapeHtml2(REPORT_VISIBILITY_COPY.title)}"><h2>${escapeHtml2(REPORT_VISIBILITY_COPY.title)}</h2><p>${escapeHtml2(visibility.selection.mode === "advanced" ? REPORT_VISIBILITY_COPY.description : REPORT_VISIBILITY_COPY.simpleDescription)}</p><p>${escapeHtml2(reportVisibilityMeasurementLabel(visibility))}</p><div class="table-scroll">${summary2}</div>${trend}</section>`;
30877
30908
  }
30878
30909
 
@@ -31402,7 +31433,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31402
31433
  const sourceRows = db.select({ id: trafficSources.id }).from(trafficSources).where(
31403
31434
  and29(
31404
31435
  eq33(trafficSources.projectId, projectId),
31405
- ne6(trafficSources.status, TrafficSourceStatuses.archived)
31436
+ ne5(trafficSources.status, TrafficSourceStatuses.archived)
31406
31437
  )
31407
31438
  ).all();
31408
31439
  if (sourceRows.length === 0) return null;
@@ -31420,7 +31451,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31420
31451
  eq33(crawlerEventsHourly.projectId, projectId),
31421
31452
  eq33(crawlerEventsHourly.verificationStatus, VerificationStatuses.verified),
31422
31453
  gte8(crawlerEventsHourly.tsHour, windowStartIso),
31423
- exclusiveEnd ? lt5(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31454
+ exclusiveEnd ? lt6(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31424
31455
  )
31425
31456
  ).get()?.total ?? 0
31426
31457
  );
@@ -31428,9 +31459,9 @@ function buildServerActivity(db, projectId, windowDays2) {
31428
31459
  db.select({ total: sql11`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
31429
31460
  and29(
31430
31461
  eq33(crawlerEventsHourly.projectId, projectId),
31431
- ne6(crawlerEventsHourly.verificationStatus, VerificationStatuses.verified),
31462
+ ne5(crawlerEventsHourly.verificationStatus, VerificationStatuses.verified),
31432
31463
  gte8(crawlerEventsHourly.tsHour, windowStartIso),
31433
- exclusiveEnd ? lt5(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31464
+ exclusiveEnd ? lt6(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31434
31465
  )
31435
31466
  ).get()?.total ?? 0
31436
31467
  );
@@ -31444,7 +31475,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31444
31475
  eq33(aiReferralEventsHourly.projectId, projectId),
31445
31476
  countableReferralCondition(),
31446
31477
  gte8(aiReferralEventsHourly.tsHour, windowStartIso),
31447
- exclusiveEnd ? lt5(aiReferralEventsHourly.tsHour, windowEndIso) : lte6(aiReferralEventsHourly.tsHour, windowEndIso)
31478
+ exclusiveEnd ? lt6(aiReferralEventsHourly.tsHour, windowEndIso) : lte6(aiReferralEventsHourly.tsHour, windowEndIso)
31448
31479
  )
31449
31480
  ).get();
31450
31481
  return aiReferralClassCounts(Number(row?.total ?? 0), Number(row?.paid ?? 0), Number(row?.organic ?? 0));
@@ -31454,7 +31485,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31454
31485
  and29(
31455
31486
  eq33(aiUserFetchEventsHourly.projectId, projectId),
31456
31487
  gte8(aiUserFetchEventsHourly.tsHour, windowStartIso),
31457
- exclusiveEnd ? lt5(aiUserFetchEventsHourly.tsHour, windowEndIso) : lte6(aiUserFetchEventsHourly.tsHour, windowEndIso)
31488
+ exclusiveEnd ? lt6(aiUserFetchEventsHourly.tsHour, windowEndIso) : lte6(aiUserFetchEventsHourly.tsHour, windowEndIso)
31458
31489
  )
31459
31490
  ).get()?.total ?? 0
31460
31491
  );
@@ -31495,7 +31526,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31495
31526
  and29(
31496
31527
  eq33(crawlerEventsHourly.projectId, projectId),
31497
31528
  gte8(crawlerEventsHourly.tsHour, priorStart),
31498
- lt5(crawlerEventsHourly.tsHour, headlineStart)
31529
+ lt6(crawlerEventsHourly.tsHour, headlineStart)
31499
31530
  )
31500
31531
  ).groupBy(crawlerEventsHourly.operator).all();
31501
31532
  const referralByOperatorRows = db.select({
@@ -31786,7 +31817,7 @@ function buildInsightList(db, projectId, locationFilter) {
31786
31817
  and29(
31787
31818
  eq33(runs.projectId, projectId),
31788
31819
  eq33(runs.kind, RunKinds["answer-visibility"]),
31789
- or5(eq33(runs.status, RunStatuses.completed), eq33(runs.status, RunStatuses.partial)),
31820
+ or6(eq33(runs.status, RunStatuses.completed), eq33(runs.status, RunStatuses.partial)),
31790
31821
  notProbeRun()
31791
31822
  )
31792
31823
  ).orderBy(desc14(runs.createdAt)).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter).slice(0, INSIGHT_LOOKBACK_RUNS).map((r) => r.id);
@@ -31932,11 +31963,6 @@ function buildProviderLocationHandling(providersInRun) {
31932
31963
  };
31933
31964
  });
31934
31965
  }
31935
- function compactList(items, limit = 3) {
31936
- const visible = items.slice(0, limit);
31937
- const extra = items.length - visible.length;
31938
- return extra > 0 ? `${visible.join(", ")}, +${extra} more` : visible.join(", ");
31939
- }
31940
31966
  function contentActionVerb(action) {
31941
31967
  switch (action) {
31942
31968
  case "create":
@@ -31954,7 +31980,7 @@ function confidenceFromEvidence(count4) {
31954
31980
  if (count4 >= 1) return "medium";
31955
31981
  return "low";
31956
31982
  }
31957
- function actionAudienceMatches2(action, audience) {
31983
+ function actionAudienceMatches(action, audience) {
31958
31984
  return action.audience === "both" || action.audience === audience;
31959
31985
  }
31960
31986
  function configuredCompetitorDomainsForActionPlan(db, projectId, projectCompetitorDomains) {
@@ -32065,10 +32091,10 @@ function buildReportActionPlan(input) {
32065
32091
  if (input.gsc && (input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0)) {
32066
32092
  const evidence = [];
32067
32093
  if (input.gsc.trackedButNoGsc.length > 0) {
32068
- evidence.push(`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`);
32094
+ evidence.push(`Tracked with no GSC demand: ${reportCompactList(input.gsc.trackedButNoGsc)}`);
32069
32095
  }
32070
32096
  if (input.gsc.gscButNotTracked.length > 0) {
32071
- evidence.push(`Search demand not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`);
32097
+ evidence.push(`Search demand not tracked in AEO: ${reportCompactList(input.gsc.gscButNotTracked)}`);
32072
32098
  }
32073
32099
  actions.push({
32074
32100
  audience: "agency",
@@ -32100,8 +32126,8 @@ function buildReportActionPlan(input) {
32100
32126
  "They are stronger evidence than a generic topic list because the model is already retrieving competing content."
32101
32127
  ],
32102
32128
  evidence: [
32103
- `"${topGap.query}" missed at ${Math.round(topGap.missRate * 100)}% with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
32104
- `Cited competitors: ${compactList(topGap.competitorDomains)}`
32129
+ `"${topGap.query}" missed at ${formatWholePercent(topGap.missRate)} with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
32130
+ `Cited competitors: ${reportCompactList(topGap.competitorDomains)}`
32105
32131
  ],
32106
32132
  successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
32107
32133
  confidence: topGap.competitorCount >= 2 ? "high" : "medium"
@@ -32111,10 +32137,10 @@ function buildReportActionPlan(input) {
32111
32137
  const ignoredProviders = input.providerLocationHandling.filter((p) => p.treatment === "ignored" || p.treatment === "browser-geo").map((p) => p.provider);
32112
32138
  const evidence = [
32113
32139
  `Current report location: ${input.reportLocation.label}`,
32114
- `Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`
32140
+ `Other configured locations: ${reportCompactList(input.reportLocation.otherConfiguredLabels)}`
32115
32141
  ];
32116
32142
  if (ignoredProviders.length > 0) {
32117
- evidence.push(`Providers with weak/indirect location handling: ${compactList(ignoredProviders)}`);
32143
+ evidence.push(`Providers with weak/indirect location handling: ${reportCompactList(ignoredProviders)}`);
32118
32144
  }
32119
32145
  actions.push({
32120
32146
  audience: "agency",
@@ -32186,7 +32212,7 @@ function buildClientSummary(reportLike) {
32186
32212
  return {
32187
32213
  headline,
32188
32214
  overview,
32189
- actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches2(a, "client")).slice(0, 3),
32215
+ actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches(a, "client")).slice(0, 3),
32190
32216
  confidenceNotes
32191
32217
  };
32192
32218
  }
@@ -32211,8 +32237,8 @@ function buildAgencyDiagnostics(input) {
32211
32237
  detail: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "The tracked AEO query set and real search demand are not fully aligned." : "Tracked AEO queries and high-impression non-brand GSC queries are aligned for the current window.",
32212
32238
  severity: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "caution" : "positive",
32213
32239
  evidence: [
32214
- ...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`] : [],
32215
- ...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`] : []
32240
+ ...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${reportCompactList(input.gsc.trackedButNoGsc)}`] : [],
32241
+ ...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${reportCompactList(input.gsc.gscButNotTracked)}`] : []
32216
32242
  ]
32217
32243
  });
32218
32244
  }
@@ -32237,7 +32263,7 @@ function buildAgencyDiagnostics(input) {
32237
32263
  evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
32238
32264
  });
32239
32265
  return {
32240
- priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
32266
+ priorities: input.actionPlan.filter((a) => actionAudienceMatches(a, "agency")).slice(0, 6),
32241
32267
  diagnostics
32242
32268
  };
32243
32269
  }
@@ -32357,7 +32383,7 @@ function buildWhatsChanged(input) {
32357
32383
  function buildProjectReport(db, projectName, periodDays) {
32358
32384
  const project = resolveProject(db, projectName);
32359
32385
  const queryLookup = loadQueryLookup(db, project.id);
32360
- const canonicalVisibility = readVisibilityReport(db, project, { queryClass: "all", scope: "project" });
32386
+ const canonicalVisibility = readVisibilityReport(db, project, { queryClass: "all", scope: "project" }, { includeComparison: false });
32361
32387
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
32362
32388
  const historyWindow = { from: new Date(Date.parse(generatedAt) - periodDays * 864e5).toISOString(), to: generatedAt };
32363
32389
  const visibility = {
@@ -32668,10 +32694,10 @@ async function reportRoutes(app) {
32668
32694
  }
32669
32695
 
32670
32696
  // ../api-routes/src/organic-evidence.ts
32671
- import { and as and31, desc as desc15, eq as eq35, gte as gte10, lte as lte8, or as or7, sql as sql13 } from "drizzle-orm";
32697
+ import { and as and31, desc as desc15, eq as eq35, gte as gte10, lte as lte8, or as or8, sql as sql13 } from "drizzle-orm";
32672
32698
 
32673
32699
  // ../api-routes/src/ga-measurement-analysis.ts
32674
- import { and as and30, eq as eq34, gte as gte9, lte as lte7, or as or6, sql as sql12 } from "drizzle-orm";
32700
+ import { and as and30, eq as eq34, gte as gte9, lte as lte7, or as or7, sql as sql12 } from "drizzle-orm";
32675
32701
  var UNKNOWN_LANDING_PAGE = "(not set)";
32676
32702
  function addDays(date, offset) {
32677
32703
  const value = /* @__PURE__ */ new Date(`${date}T00:00:00.000Z`);
@@ -32845,7 +32871,7 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
32845
32871
  if (parsedHostScope.data === "marketing") {
32846
32872
  const lowerHost = sql12`lower(${hostColumn})`;
32847
32873
  const normalizedHost2 = sql12`case when ${lowerHost} like 'www.%' then substr(${lowerHost}, 5) else ${lowerHost} end`;
32848
- conditions.push(or6(...marketingHosts.flatMap((host) => [
32874
+ conditions.push(or7(...marketingHosts.flatMap((host) => [
32849
32875
  sql12`${normalizedHost2} = ${host}`,
32850
32876
  sql12`${normalizedHost2} like ${`%.${host}`}`
32851
32877
  ])));
@@ -32853,14 +32879,14 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
32853
32879
  if (pathPrefix && pathPrefix !== "/") {
32854
32880
  const rawPath = sql12`case when instr(${rawPathColumn}, '?') = 0 then ${rawPathColumn} else substr(${rawPathColumn}, 1, instr(${rawPathColumn}, '?') - 1) end`;
32855
32881
  const landingPath = sql12`case when trim(coalesce(${normalizedPathColumn}, '')) = '' then ${rawPath} else ${normalizedPathColumn} end`;
32856
- conditions.push(or6(sql12`${landingPath} = ${pathPrefix}`, sql12`${landingPath} like ${`${pathPrefix}/%`}`));
32882
+ conditions.push(or7(sql12`${landingPath} = ${pathPrefix}`, sql12`${landingPath} like ${`${pathPrefix}/%`}`));
32857
32883
  }
32858
32884
  return conditions;
32859
32885
  };
32860
32886
  const acquisitionScope = scopedConditions(gaAcquisitionDaily.hostName, gaAcquisitionDaily.landingPageNormalized, gaAcquisitionDaily.landingPage);
32861
32887
  const leadLandingScope = scopedConditions(gaLeadEventsDaily.hostName, gaLeadEventsDaily.landingPageNormalized, gaLeadEventsDaily.landingPage);
32862
32888
  const acquisitionAnchor = db.select({ date: sql12`max(${gaAcquisitionDaily.date})` }).from(gaAcquisitionDaily).where(and30(eq34(gaAcquisitionDaily.projectId, project.id), ...acquisitionScope)).get()?.date ?? null;
32863
- const leadAnchor = db.select({ date: sql12`max(${gaLeadEventsDaily.date})` }).from(gaLeadEventsDaily).where(and30(eq34(gaLeadEventsDaily.projectId, project.id), or6(
32889
+ const leadAnchor = db.select({ date: sql12`max(${gaLeadEventsDaily.date})` }).from(gaLeadEventsDaily).where(and30(eq34(gaLeadEventsDaily.projectId, project.id), or7(
32864
32890
  eq34(gaLeadEventsDaily.attributionScope, "channel"),
32865
32891
  and30(eq34(gaLeadEventsDaily.attributionScope, "landing-page"), ...leadLandingScope)
32866
32892
  ))).get()?.date ?? null;
@@ -32881,7 +32907,7 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
32881
32907
  date: sql12`min(${gaDailyTotals.date})`
32882
32908
  }).from(gaDailyTotals).where(and30(
32883
32909
  eq34(gaDailyTotals.projectId, project.id),
32884
- or6(
32910
+ or7(
32885
32911
  sql12`${gaDailyTotals.engagementRate} is not null`,
32886
32912
  sql12`${gaDailyTotals.newUsers} is not null`
32887
32913
  )
@@ -33228,7 +33254,7 @@ function buildOrganicEvidence(db, projectName, periodDays) {
33228
33254
  observedDays: sql13`count(distinct ${gaAcquisitionDaily.date})`
33229
33255
  }).from(gaAcquisitionDaily).where(and31(
33230
33256
  eq35(gaAcquisitionDaily.projectId, project.id),
33231
- or7(...normalizedMarketingHosts.map((candidate) => sql13`lower(trim(${gaAcquisitionDaily.hostName})) = ${candidate} or lower(trim(${gaAcquisitionDaily.hostName})) like ${`%.${candidate}`}`))
33257
+ or8(...normalizedMarketingHosts.map((candidate) => sql13`lower(trim(${gaAcquisitionDaily.hostName})) = ${candidate} or lower(trim(${gaAcquisitionDaily.hostName})) like ${`%.${candidate}`}`))
33232
33258
  )).get()) : null;
33233
33259
  const gaCoverage = nativeAcquisitionActive ? nativeCoverage : legacyGaCoverage;
33234
33260
  const asOfDate = gscCoverage?.endDate ?? gaCoverage?.endDate ?? null;
@@ -34570,7 +34596,7 @@ async function resultsExportRoutes(app) {
34570
34596
  }
34571
34597
 
34572
34598
  // ../api-routes/src/composites.ts
34573
- import { eq as eq39, and as and35, desc as desc17, sql as sql14, like as like3, or as or8, inArray as inArray18 } from "drizzle-orm";
34599
+ import { eq as eq39, and as and35, desc as desc17, sql as sql14, like as like3, or as or9, inArray as inArray18 } from "drizzle-orm";
34574
34600
  var TOP_INSIGHT_LIMIT = 5;
34575
34601
  var SEARCH_HIT_HARD_LIMIT = 50;
34576
34602
  var SEARCH_SNIPPET_RADIUS = 80;
@@ -34767,7 +34793,7 @@ async function compositeRoutes(app) {
34767
34793
  }).from(querySnapshots).innerJoin(queries, eq39(querySnapshots.queryId, queries.id)).where(
34768
34794
  and35(
34769
34795
  eq39(queries.projectId, project.id),
34770
- or8(
34796
+ or9(
34771
34797
  sql14`${querySnapshots.answerText} LIKE ${pattern} ESCAPE '\\'`,
34772
34798
  sql14`${querySnapshots.citedDomains} LIKE ${pattern} ESCAPE '\\'`,
34773
34799
  sql14`${querySnapshots.citedUrls} LIKE ${pattern} ESCAPE '\\'`,
@@ -34779,7 +34805,7 @@ async function compositeRoutes(app) {
34779
34805
  const insightMatches = app.db.select().from(insights).where(
34780
34806
  and35(
34781
34807
  eq39(insights.projectId, project.id),
34782
- or8(
34808
+ or9(
34783
34809
  like3(insights.title, pattern),
34784
34810
  like3(insights.query, pattern),
34785
34811
  sql14`${insights.recommendation} LIKE ${pattern} ESCAPE '\\'`,
@@ -47297,7 +47323,7 @@ async function googleRoutes(app, opts) {
47297
47323
 
47298
47324
  // ../api-routes/src/google-marketing.ts
47299
47325
  import crypto33 from "crypto";
47300
- import { and as and38, desc as desc20, eq as eq45, isNull as isNull6, lt as lt6, ne as ne7, or as or9, sql as sql16 } from "drizzle-orm";
47326
+ import { and as and38, desc as desc20, eq as eq45, isNull as isNull6, lt as lt7, ne as ne6, or as or10, sql as sql16 } from "drizzle-orm";
47301
47327
  import { z as z4 } from "zod";
47302
47328
  var snapshotPageQuerySchema = z4.object({
47303
47329
  limit: z4.coerce.number().int().min(1).max(GOOGLE_MARKETING_STORED_SNAPSHOT_PAGE_MAX).optional(),
@@ -48512,7 +48538,7 @@ async function googleMarketingRoutes(app, opts) {
48512
48538
  const existing = tx.select().from(runs).where(and38(
48513
48539
  eq45(runs.projectId, project.id),
48514
48540
  eq45(runs.kind, RunKinds["google-ads-sync"]),
48515
- or9(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48541
+ or10(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48516
48542
  )).orderBy(desc20(runs.createdAt)).get();
48517
48543
  if (existing) {
48518
48544
  run = existing;
@@ -48548,7 +48574,7 @@ async function googleMarketingRoutes(app, opts) {
48548
48574
  const existing = tx.select().from(runs).where(and38(
48549
48575
  eq45(runs.projectId, project.id),
48550
48576
  eq45(runs.kind, RunKinds["gtm-sync"]),
48551
- or9(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48577
+ or10(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48552
48578
  )).orderBy(desc20(runs.createdAt)).get();
48553
48579
  if (existing) {
48554
48580
  run = existing;
@@ -48578,9 +48604,9 @@ async function googleMarketingRoutes(app, opts) {
48578
48604
  const cursor = decodeSnapshotCursor(query.cursor);
48579
48605
  const conditions = [eq45(googleAdsRawSnapshots.projectId, project.id)];
48580
48606
  if (cursor) {
48581
- conditions.push(or9(
48582
- lt6(googleAdsRawSnapshots.capturedAt, cursor.capturedAt),
48583
- and38(eq45(googleAdsRawSnapshots.capturedAt, cursor.capturedAt), lt6(googleAdsRawSnapshots.id, cursor.id))
48607
+ conditions.push(or10(
48608
+ lt7(googleAdsRawSnapshots.capturedAt, cursor.capturedAt),
48609
+ and38(eq45(googleAdsRawSnapshots.capturedAt, cursor.capturedAt), lt7(googleAdsRawSnapshots.id, cursor.id))
48584
48610
  ));
48585
48611
  }
48586
48612
  const limit = query.limit ?? 50;
@@ -48610,9 +48636,9 @@ async function googleMarketingRoutes(app, opts) {
48610
48636
  const cursor = decodeSnapshotCursor(query.cursor);
48611
48637
  const conditions = [eq45(gtmRawSnapshots.projectId, project.id)];
48612
48638
  if (cursor) {
48613
- conditions.push(or9(
48614
- lt6(gtmRawSnapshots.capturedAt, cursor.capturedAt),
48615
- and38(eq45(gtmRawSnapshots.capturedAt, cursor.capturedAt), lt6(gtmRawSnapshots.id, cursor.id))
48639
+ conditions.push(or10(
48640
+ lt7(gtmRawSnapshots.capturedAt, cursor.capturedAt),
48641
+ and38(eq45(gtmRawSnapshots.capturedAt, cursor.capturedAt), lt7(gtmRawSnapshots.id, cursor.id))
48616
48642
  ));
48617
48643
  }
48618
48644
  const limit = query.limit ?? 50;
@@ -48798,7 +48824,7 @@ async function googleMarketingRoutes(app, opts) {
48798
48824
  const duplicate = app.db.select({ id: conversionTrackingContracts.id }).from(conversionTrackingContracts).where(and38(
48799
48825
  eq45(conversionTrackingContracts.projectId, project.id),
48800
48826
  eq45(conversionTrackingContracts.name, body.name),
48801
- ne7(conversionTrackingContracts.id, existing.id)
48827
+ ne6(conversionTrackingContracts.id, existing.id)
48802
48828
  )).get();
48803
48829
  if (duplicate) throw alreadyExists("Conversion tracking contract", body.name);
48804
48830
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -48887,7 +48913,7 @@ async function googleMarketingRoutes(app, opts) {
48887
48913
 
48888
48914
  // ../api-routes/src/ads.ts
48889
48915
  import crypto36 from "crypto";
48890
- import { eq as eq47, and as and40, asc as asc9, desc as desc21, gt as gt3, gte as gte12, lt as lt7, lte as lte11, ne as ne8, inArray as inArray20, or as or11, isNull as isNull8, isNotNull as isNotNull4, sql as sql18 } from "drizzle-orm";
48916
+ import { eq as eq47, and as and40, asc as asc9, desc as desc21, gt as gt3, gte as gte12, lt as lt8, lte as lte11, ne as ne7, inArray as inArray20, or as or12, isNull as isNull8, isNotNull as isNotNull4, sql as sql18 } from "drizzle-orm";
48891
48917
 
48892
48918
  // ../api-routes/src/ads-activation.ts
48893
48919
  import crypto34 from "crypto";
@@ -50273,7 +50299,7 @@ async function executeApprovedAdsActivation(dependencies, request) {
50273
50299
 
50274
50300
  // ../api-routes/src/ads-activation-routes.ts
50275
50301
  import crypto35 from "crypto";
50276
- import { and as and39, asc as asc8, eq as eq46, gt as gt2, isNotNull as isNotNull3, isNull as isNull7, lte as lte10, or as or10, sql as sql17 } from "drizzle-orm";
50302
+ import { and as and39, asc as asc8, eq as eq46, gt as gt2, isNotNull as isNotNull3, isNull as isNull7, lte as lte10, or as or11, sql as sql17 } from "drizzle-orm";
50277
50303
  var MAX_ACTIVATION_GRANT_LIFETIME_MS = 24 * 60 * 60 * 1e3;
50278
50304
  var ACTIVATION_LEASE_MS = 5 * 6e4;
50279
50305
  var ACTIVATION_REVOCATION_SETTLEMENT_MS = ACTIVATION_LEASE_MS;
@@ -50399,7 +50425,7 @@ function createActivationStore(app, request) {
50399
50425
  }).where(and39(
50400
50426
  eq46(adsOperations.id, existing.id),
50401
50427
  eq46(adsOperations.state, AdsOperationStates.pending),
50402
- or10(
50428
+ or11(
50403
50429
  isNull7(adsOperations.leaseOwner),
50404
50430
  eq46(adsOperations.leaseOwner, input.leaseOwner),
50405
50431
  isNull7(adsOperations.leaseExpiresAt),
@@ -50817,29 +50843,29 @@ async function executeActivationGrant(app, opts, request, project, executorApiKe
50817
50843
  return activationResponse(opts, result.grant, result.operation);
50818
50844
  }
50819
50845
  function selectWatchdogOperations(app, nowIso, batchSize, allowLiveRevocationLease) {
50820
- const leaseAvailable = or10(
50846
+ const leaseAvailable = or11(
50821
50847
  isNull7(adsOperations.leaseOwner),
50822
50848
  isNull7(adsOperations.leaseExpiresAt),
50823
50849
  lte10(adsOperations.leaseExpiresAt, nowIso)
50824
50850
  );
50825
- const campaignHasMutationEvidence = or10(
50851
+ const campaignHasMutationEvidence = or11(
50826
50852
  eq46(adsOperationSteps.state, AdsOperationStepStates.executing),
50827
50853
  eq46(adsOperationSteps.state, AdsOperationStepStates.active),
50828
50854
  eq46(adsOperationSteps.state, AdsOperationStepStates.rollback_executing),
50829
50855
  eq46(adsOperationSteps.state, AdsOperationStepStates.rollback_failed),
50830
50856
  eq46(adsOperationSteps.state, AdsOperationStepStates.unknown)
50831
50857
  );
50832
- const eligibleState = allowLiveRevocationLease ? or10(
50858
+ const eligibleState = allowLiveRevocationLease ? or11(
50833
50859
  eq46(adsOperations.state, AdsOperationStates.unknown),
50834
50860
  and39(
50835
50861
  eq46(adsOperations.state, AdsOperationStates.pending),
50836
- or10(
50862
+ or11(
50837
50863
  isNotNull3(adsActivationGrants.revocationRequestedAt),
50838
50864
  and39(leaseAvailable, campaignHasMutationEvidence)
50839
50865
  )
50840
50866
  )
50841
50867
  ) : and39(
50842
- or10(
50868
+ or11(
50843
50869
  eq46(adsOperations.state, AdsOperationStates.unknown),
50844
50870
  eq46(adsOperations.state, AdsOperationStates.pending)
50845
50871
  ),
@@ -50983,7 +51009,7 @@ async function containUrgentAdsActivationCampaignsOnce(app, opts) {
50983
51009
  lastReconciledAt: (/* @__PURE__ */ new Date()).toISOString()
50984
51010
  }).where(and39(
50985
51011
  eq46(adsOperations.id, operation.id),
50986
- or10(
51012
+ or11(
50987
51013
  eq46(adsOperations.state, AdsOperationStates.unknown),
50988
51014
  eq46(adsOperations.state, AdsOperationStates.pending)
50989
51015
  )
@@ -51009,11 +51035,11 @@ async function sweepStaleAdsActivationsOnce(app, opts) {
51009
51035
  updatedAt: nowIso
51010
51036
  }).where(and39(
51011
51037
  eq46(adsOperations.id, operation.id),
51012
- or10(
51038
+ or11(
51013
51039
  eq46(adsOperations.state, AdsOperationStates.unknown),
51014
51040
  and39(
51015
51041
  eq46(adsOperations.state, AdsOperationStates.pending),
51016
- or10(
51042
+ or11(
51017
51043
  isNull7(adsOperations.leaseOwner),
51018
51044
  isNull7(adsOperations.leaseExpiresAt),
51019
51045
  lte10(adsOperations.leaseExpiresAt, nowIso)
@@ -51046,11 +51072,11 @@ async function sweepStaleAdsActivationsOnce(app, opts) {
51046
51072
  const failedAt = (/* @__PURE__ */ new Date()).toISOString();
51047
51073
  app.db.update(adsOperations).set({ updatedAt: failedAt }).where(and39(
51048
51074
  eq46(adsOperations.id, operation.id),
51049
- or10(
51075
+ or11(
51050
51076
  eq46(adsOperations.state, AdsOperationStates.unknown),
51051
51077
  and39(
51052
51078
  eq46(adsOperations.state, AdsOperationStates.pending),
51053
- or10(
51079
+ or11(
51054
51080
  isNull7(adsOperations.leaseOwner),
51055
51081
  isNull7(adsOperations.leaseExpiresAt),
51056
51082
  lte10(adsOperations.leaseExpiresAt, failedAt)
@@ -52236,7 +52262,7 @@ function claimOperationForReconciliation(app, row, leaseOwner, now, leaseMs, pol
52236
52262
  enforceBackoff ? lte11(adsOperations.updatedAt, unknownCutoff) : void 0
52237
52263
  ) : and40(
52238
52264
  eq47(adsOperations.state, AdsOperationStates.reconciling),
52239
- or11(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52265
+ or12(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52240
52266
  );
52241
52267
  const claimed = app.db.update(adsOperations).set({
52242
52268
  state: AdsOperationStates.reconciling,
@@ -52248,7 +52274,7 @@ function claimOperationForReconciliation(app, row, leaseOwner, now, leaseMs, pol
52248
52274
  eq47(adsOperations.id, row.id),
52249
52275
  eq47(adsOperations.projectId, row.projectId),
52250
52276
  eq47(adsOperations.reconcileAttempts, row.reconcileAttempts),
52251
- lt7(adsOperations.reconcileAttempts, policy.maxAttempts),
52277
+ lt8(adsOperations.reconcileAttempts, policy.maxAttempts),
52252
52278
  claimableState
52253
52279
  )).returning({ id: adsOperations.id }).all();
52254
52280
  if (claimed.length === 0) return void 0;
@@ -52558,28 +52584,28 @@ async function sweepAdsOperationsOnce(app, opts, config) {
52558
52584
  );
52559
52585
  }
52560
52586
  );
52561
- const retryableStateWhere = or11(
52587
+ const retryableStateWhere = or12(
52562
52588
  and40(
52563
52589
  eq47(adsOperations.state, AdsOperationStates.pending),
52564
52590
  lte11(adsOperations.updatedAt, pendingCutoff)
52565
52591
  ),
52566
52592
  and40(
52567
52593
  eq47(adsOperations.state, AdsOperationStates.unknown),
52568
- or11(...unknownRetryWindows)
52594
+ or12(...unknownRetryWindows)
52569
52595
  ),
52570
52596
  and40(
52571
52597
  eq47(adsOperations.state, AdsOperationStates.reconciling),
52572
- or11(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52598
+ or12(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52573
52599
  )
52574
52600
  );
52575
52601
  const staleReconcileWhere = and40(
52576
- lt7(adsOperations.reconcileAttempts, config.policy.maxAttempts),
52577
- ne8(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52578
- or11(
52602
+ lt8(adsOperations.reconcileAttempts, config.policy.maxAttempts),
52603
+ ne7(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52604
+ or12(
52579
52605
  eq47(adsOperations.reconcileStrategy, AdsReconcileStrategies.known_entity),
52580
52606
  and40(
52581
52607
  eq47(adsOperations.reconcileStrategy, AdsReconcileStrategies.create_fingerprint),
52582
- or11(
52608
+ or12(
52583
52609
  isNotNull4(adsOperations.entityId),
52584
52610
  inArray20(adsOperations.state, [
52585
52611
  AdsOperationStates.pending,
@@ -52591,9 +52617,9 @@ async function sweepAdsOperationsOnce(app, opts, config) {
52591
52617
  retryableStateWhere
52592
52618
  );
52593
52619
  const exhaustedRows = app.db.select().from(adsOperations).where(and40(
52594
- ne8(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52620
+ ne7(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52595
52621
  gte12(adsOperations.reconcileAttempts, config.policy.maxAttempts),
52596
- or11(
52622
+ or12(
52597
52623
  and40(
52598
52624
  eq47(adsOperations.state, AdsOperationStates.pending),
52599
52625
  lte11(adsOperations.updatedAt, pendingCutoff)
@@ -52601,12 +52627,12 @@ async function sweepAdsOperationsOnce(app, opts, config) {
52601
52627
  eq47(adsOperations.state, AdsOperationStates.unknown),
52602
52628
  and40(
52603
52629
  eq47(adsOperations.state, AdsOperationStates.reconciling),
52604
- or11(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52630
+ or12(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52605
52631
  )
52606
52632
  ),
52607
- or11(
52633
+ or12(
52608
52634
  isNull8(adsOperations.errorCode),
52609
- ne8(adsOperations.errorCode, ADS_RECONCILIATION_QUARANTINED)
52635
+ ne7(adsOperations.errorCode, ADS_RECONCILIATION_QUARANTINED)
52610
52636
  )
52611
52637
  )).orderBy(asc9(adsOperations.updatedAt)).limit(config.batchSize).all();
52612
52638
  for (const row of exhaustedRows) {
@@ -52952,7 +52978,7 @@ async function adsRoutes(app, opts) {
52952
52978
  const rows = app.db.select().from(adsOperations).where(and40(
52953
52979
  eq47(adsOperations.projectId, project.id),
52954
52980
  inArray20(adsOperations.state, parsed.data.state),
52955
- cursor ? or11(
52981
+ cursor ? or12(
52956
52982
  gt3(adsOperations.createdAt, cursor.createdAt),
52957
52983
  and40(
52958
52984
  eq47(adsOperations.createdAt, cursor.createdAt),
@@ -58084,7 +58110,7 @@ function pruneCachedRelease(release, opts = {}) {
58084
58110
  }
58085
58111
 
58086
58112
  // ../api-routes/src/backlinks-filter.ts
58087
- import { and as and44, ne as ne9, notLike } from "drizzle-orm";
58113
+ import { and as and44, ne as ne8, notLike } from "drizzle-orm";
58088
58114
  var BACKLINK_FILTER_PATTERNS = [
58089
58115
  "*.google.com",
58090
58116
  "*.googleusercontent.com",
@@ -58101,10 +58127,10 @@ function backlinkCrawlerExclusionClause() {
58101
58127
  for (const pattern of BACKLINK_FILTER_PATTERNS) {
58102
58128
  if (pattern.startsWith("*.")) {
58103
58129
  const suffix = pattern.slice(2);
58104
- conditions.push(ne9(backlinkDomains.linkingDomain, suffix));
58130
+ conditions.push(ne8(backlinkDomains.linkingDomain, suffix));
58105
58131
  conditions.push(notLike(backlinkDomains.linkingDomain, `%.${suffix}`));
58106
58132
  } else {
58107
- conditions.push(ne9(backlinkDomains.linkingDomain, pattern));
58133
+ conditions.push(ne8(backlinkDomains.linkingDomain, pattern));
58108
58134
  }
58109
58135
  }
58110
58136
  const combined = and44(...conditions);
@@ -75138,10 +75164,10 @@ async function ackCloudflareQueueMessages(client, options) {
75138
75164
 
75139
75165
  // ../api-routes/src/traffic-event-ingest.ts
75140
75166
  import crypto43 from "crypto";
75141
- import { and as and47, eq as eq53, gte as gte13, lt as lt9, lte as lte12, sql as sql22 } from "drizzle-orm";
75167
+ import { and as and47, eq as eq53, gte as gte13, lt as lt10, lte as lte12, sql as sql22 } from "drizzle-orm";
75142
75168
 
75143
75169
  // ../api-routes/src/raw-event-sample-retention.ts
75144
- import { and as and46, eq as eq52, lt as lt8, sql as sql21 } from "drizzle-orm";
75170
+ import { and as and46, eq as eq52, lt as lt9, sql as sql21 } from "drizzle-orm";
75145
75171
  var RAW_EVENT_SAMPLE_RETENTION_MS = 30 * 24 * 60 * 6e4;
75146
75172
  var RAW_EVENT_SAMPLE_RETENTION_SWEEP_INTERVAL_MS = 24 * 60 * 6e4;
75147
75173
  var CANONICAL_UTC_TIMESTAMP_GLOB = "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z";
@@ -75156,7 +75182,7 @@ function deleteExpiredRawEventSamples(db, cutoff, sourceId) {
75156
75182
  db.delete(rawEventSamples).where(and46(
75157
75183
  sourceScope,
75158
75184
  sql21`${rawEventSamples.ts} GLOB ${CANONICAL_UTC_TIMESTAMP_GLOB}`,
75159
- lt8(rawEventSamples.ts, cutoff)
75185
+ lt9(rawEventSamples.ts, cutoff)
75160
75186
  )).run();
75161
75187
  db.delete(rawEventSamples).where(and46(
75162
75188
  sourceScope,
@@ -75418,7 +75444,7 @@ function writeTrafficEventBatch(opts) {
75418
75444
  const existing = tx.select({ total: sql22`COUNT(*)` }).from(rawEventSamples).where(and47(
75419
75445
  eq53(rawEventSamples.sourceId, opts.sourceId),
75420
75446
  gte13(rawEventSamples.ts, hour.start),
75421
- lt9(rawEventSamples.ts, hour.end)
75447
+ lt10(rawEventSamples.ts, hour.end)
75422
75448
  )).get();
75423
75449
  remaining = Math.max(0, hourlySampleLimit - Number(existing?.total ?? 0));
75424
75450
  }
@@ -75475,7 +75501,7 @@ function writeTrafficEventBatch(opts) {
75475
75501
  }
75476
75502
 
75477
75503
  // ../api-routes/src/traffic-sync-lease.ts
75478
- import { and as and48, eq as eq54, isNull as isNull9, lte as lte13, or as or12 } from "drizzle-orm";
75504
+ import { and as and48, eq as eq54, isNull as isNull9, lte as lte13, or as or13 } from "drizzle-orm";
75479
75505
  function leaseExpiresAt(now, ttlMs) {
75480
75506
  const nowMs = Date.parse(now);
75481
75507
  if (!Number.isFinite(nowMs)) throw new RangeError("Traffic sync lease now must be an ISO instant");
@@ -75490,7 +75516,7 @@ function tryClaimTrafficSyncLease(input) {
75490
75516
  syncLeaseExpiresAt: expiresAt
75491
75517
  }).where(and48(
75492
75518
  eq54(trafficSources.id, input.sourceId),
75493
- or12(
75519
+ or13(
75494
75520
  isNull9(trafficSources.syncLeaseOwner),
75495
75521
  lte13(trafficSources.syncLeaseExpiresAt, input.now),
75496
75522
  eq54(trafficSources.syncLeaseOwner, input.owner)
@@ -80794,7 +80820,7 @@ var RUNTIME_STATE_CHECKS = [
80794
80820
  ];
80795
80821
 
80796
80822
  // ../api-routes/src/doctor/checks/traffic-source.ts
80797
- import { and as and53, eq as eq62, gte as gte15, inArray as inArray23, ne as ne10, sql as sql24 } from "drizzle-orm";
80823
+ import { and as and53, eq as eq62, gte as gte15, inArray as inArray23, ne as ne9, sql as sql24 } from "drizzle-orm";
80798
80824
  var RECENT_DATA_WARN_DAYS = 7;
80799
80825
  var RECENT_DATA_FAIL_DAYS = 30;
80800
80826
  function isCloudflareDirectPush(source) {
@@ -80835,7 +80861,7 @@ function loadProbes(ctx) {
80835
80861
  const rows = ctx.db.select().from(trafficSources).where(
80836
80862
  and53(
80837
80863
  eq62(trafficSources.projectId, ctx.project.id),
80838
- ne10(trafficSources.status, TrafficSourceStatuses.archived)
80864
+ ne9(trafficSources.status, TrafficSourceStatuses.archived)
80839
80865
  )
80840
80866
  ).all();
80841
80867
  return rows.map((r) => ({
@@ -81987,7 +82013,7 @@ async function doctorRoutes(app, opts) {
81987
82013
 
81988
82014
  // ../api-routes/src/discovery/routes.ts
81989
82015
  import crypto46 from "crypto";
81990
- import { and as and54, desc as desc27, eq as eq63, gte as gte16, inArray as inArray24, isNull as isNull10, or as or13 } from "drizzle-orm";
82016
+ import { and as and54, desc as desc27, eq as eq63, gte as gte16, inArray as inArray24, isNull as isNull10, or as or14 } from "drizzle-orm";
81991
82017
  var MAX_INFLIGHT_DISCOVERY_AGE_MS = 2 * 60 * 60 * 1e3;
81992
82018
  async function discoveryRoutes(app, opts) {
81993
82019
  app.post("/projects/:name/discover/run", async (request, reply) => {
@@ -82037,7 +82063,7 @@ async function discoveryRoutes(app, opts) {
82037
82063
  // locations a NULL row's subset is unknowable, so it conservatively
82038
82064
  // never reuses — a one-time, bounded (2h window) non-reuse after
82039
82065
  // upgrade, never a wrong reuse.
82040
- locations.length === 0 ? or13(isNull10(discoverySessions.locations), eq63(discoverySessions.locations, locations)) : eq63(discoverySessions.locations, locations),
82066
+ locations.length === 0 ? or14(isNull10(discoverySessions.locations), eq63(discoverySessions.locations, locations)) : eq63(discoverySessions.locations, locations),
82041
82067
  // Seed provider set is identity: a different phrasing distribution
82042
82068
  // must never reuse another set's session. Null (= Gemini-only
82043
82069
  // default, including explicit ['gemini']) matches legacy rows.
@@ -82667,7 +82693,7 @@ function dedupeStrings(input) {
82667
82693
 
82668
82694
  // ../api-routes/src/technical-aeo.ts
82669
82695
  import crypto48 from "crypto";
82670
- import { and as and55, asc as asc11, count as count2, desc as desc28, eq as eq65, inArray as inArray25, isNotNull as isNotNull5, isNull as isNull11, lt as lt10, or as or14, sql as sql25 } from "drizzle-orm";
82696
+ import { and as and55, asc as asc11, count as count2, desc as desc28, eq as eq65, inArray as inArray25, isNotNull as isNotNull5, isNull as isNull11, lt as lt11, or as or15, sql as sql25 } from "drizzle-orm";
82671
82697
  import { alias } from "drizzle-orm/sqlite-core";
82672
82698
  var FETCHED_SITE_CRAWL_STATES = /* @__PURE__ */ new Set([
82673
82699
  ...SiteCrawlFetchedStates,
@@ -83063,7 +83089,7 @@ async function technicalAeoRoutes(app, opts) {
83063
83089
  return emptyScore(project.name);
83064
83090
  }
83065
83091
  const snap = latest.snap;
83066
- const previous = app.db.select({ snap: siteAuditSnapshots }).from(siteAuditSnapshots).innerJoin(runs, eq65(siteAuditSnapshots.runId, runs.id)).where(and55(...baseFilters, lt10(siteAuditSnapshots.createdAt, snap.createdAt))).orderBy(desc28(siteAuditSnapshots.createdAt)).limit(1).get()?.snap ?? null;
83092
+ const previous = app.db.select({ snap: siteAuditSnapshots }).from(siteAuditSnapshots).innerJoin(runs, eq65(siteAuditSnapshots.runId, runs.id)).where(and55(...baseFilters, lt11(siteAuditSnapshots.createdAt, snap.createdAt))).orderBy(desc28(siteAuditSnapshots.createdAt)).limit(1).get()?.snap ?? null;
83067
83093
  const deltaScore = previous ? snap.aggregateScore - previous.aggregateScore : null;
83068
83094
  const trend = deltaScore == null ? null : deltaScore > 0 ? SiteAuditTrendDirections.up : deltaScore < 0 ? SiteAuditTrendDirections.down : SiteAuditTrendDirections.flat;
83069
83095
  return {
@@ -83309,7 +83335,7 @@ async function technicalAeoRoutes(app, opts) {
83309
83335
  eq65(siteCrawlPages.runId, siteCrawlGraphNodes.runId),
83310
83336
  eq65(siteCrawlPages.attemptId, siteCrawlGraphNodes.attemptId),
83311
83337
  eq65(siteCrawlPages.nodeKey, siteCrawlGraphNodes.nodeKey)
83312
- )).where(and55(...graphScope, lt10(siteCrawlGraphNodes.sampleRank, maxNodes))).orderBy(asc11(siteCrawlGraphNodes.sampleRank)).all();
83338
+ )).where(and55(...graphScope, lt11(siteCrawlGraphNodes.sampleRank, maxNodes))).orderBy(asc11(siteCrawlGraphNodes.sampleRank)).all();
83313
83339
  const nodes = nodeRows.map(({ indexabilityReasons, canonicalNodeKey, ...row }) => ({
83314
83340
  ...row,
83315
83341
  healthState: deriveSiteHealthState({ ...row, indexabilityReasons, canonicalNodeKey })
@@ -83327,18 +83353,18 @@ async function technicalAeoRoutes(app, opts) {
83327
83353
  eq65(graphSourceNode.runId, siteCrawlGraphEdges.runId),
83328
83354
  eq65(graphSourceNode.attemptId, siteCrawlGraphEdges.attemptId),
83329
83355
  eq65(graphSourceNode.nodeKey, siteCrawlGraphEdges.sourceNodeKey),
83330
- lt10(graphSourceNode.sampleRank, maxNodes)
83356
+ lt11(graphSourceNode.sampleRank, maxNodes)
83331
83357
  )).innerJoin(graphTargetNode, and55(
83332
83358
  eq65(graphTargetNode.projectId, siteCrawlGraphEdges.projectId),
83333
83359
  eq65(graphTargetNode.runId, siteCrawlGraphEdges.runId),
83334
83360
  eq65(graphTargetNode.attemptId, siteCrawlGraphEdges.attemptId),
83335
83361
  eq65(graphTargetNode.nodeKey, siteCrawlGraphEdges.targetNodeKey),
83336
- lt10(graphTargetNode.sampleRank, maxNodes)
83362
+ lt11(graphTargetNode.sampleRank, maxNodes)
83337
83363
  )).where(and55(
83338
83364
  eq65(siteCrawlGraphEdges.projectId, project.id),
83339
83365
  eq65(siteCrawlGraphEdges.runId, snapshot.runId),
83340
83366
  eq65(siteCrawlGraphEdges.attemptId, snapshot.attemptId),
83341
- lt10(siteCrawlGraphEdges.sampleRank, maxEdges),
83367
+ lt11(siteCrawlGraphEdges.sampleRank, maxEdges),
83342
83368
  graphLinkKindFilter
83343
83369
  )).orderBy(asc11(siteCrawlGraphEdges.sampleRank)).all();
83344
83370
  const totalNodes = persistedLayout.totalNodes;
@@ -83437,7 +83463,7 @@ async function technicalAeoRoutes(app, opts) {
83437
83463
  eq65(siteCrawlEdges.attemptId, scope.attemptId),
83438
83464
  eq65(siteCrawlEdges.internal, true),
83439
83465
  isNotNull5(siteCrawlEdges.targetNodeKey),
83440
- or14(
83466
+ or15(
83441
83467
  inArray25(siteCrawlEdges.sourceNodeKey, frontier),
83442
83468
  inArray25(siteCrawlEdges.targetNodeKey, frontier)
83443
83469
  )
@@ -83684,11 +83710,11 @@ async function technicalAeoRoutes(app, opts) {
83684
83710
  if (request.query.toRunId && !afterTarget) throw notFound("Site crawl run", request.query.toRunId);
83685
83711
  const beforeTarget = request.query.fromRunId ? resolveCrawl(project.id, request.query.fromRunId) : afterTarget ? app.db.select({ snapshot: siteCrawlSnapshots, runStatus: runs.status }).from(siteCrawlSnapshots).innerJoin(runs, eq65(siteCrawlSnapshots.runId, runs.id)).where(and55(
83686
83712
  ...completeSnapshotFilters,
83687
- or14(
83688
- lt10(siteCrawlSnapshots.createdAt, afterTarget.snapshot.createdAt),
83713
+ or15(
83714
+ lt11(siteCrawlSnapshots.createdAt, afterTarget.snapshot.createdAt),
83689
83715
  and55(
83690
83716
  eq65(siteCrawlSnapshots.createdAt, afterTarget.snapshot.createdAt),
83691
- lt10(siteCrawlSnapshots.runId, afterTarget.snapshot.runId)
83717
+ lt11(siteCrawlSnapshots.runId, afterTarget.snapshot.runId)
83692
83718
  )
83693
83719
  )
83694
83720
  )).orderBy(desc28(siteCrawlSnapshots.createdAt), desc28(siteCrawlSnapshots.runId)).limit(1).get() : void 0;
@@ -84281,8 +84307,8 @@ async function technicalAeoRoutes(app, opts) {
84281
84307
  eq65(siteCrawlEdges.internal, true),
84282
84308
  linkKindFilter(linkKind)
84283
84309
  ];
84284
- const inboundMatch = request.query.nodeKey && request.query.url ? or14(eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.targetUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.targetUrl, request.query.url);
84285
- const outboundMatch = request.query.nodeKey && request.query.url ? or14(eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.sourceUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.sourceUrl, request.query.url);
84310
+ const inboundMatch = request.query.nodeKey && request.query.url ? or15(eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.targetUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.targetUrl, request.query.url);
84311
+ const outboundMatch = request.query.nodeKey && request.query.url ? or15(eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.sourceUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.sourceUrl, request.query.url);
84286
84312
  const limit = parseBoundedLimit(request.query.limit, 50, 100);
84287
84313
  const inboundRows = app.db.select().from(siteCrawlEdges).where(and55(...scope, inboundMatch)).orderBy(asc11(siteCrawlEdges.edgeKey)).limit(limit + 1).all();
84288
84314
  const outboundRows = app.db.select().from(siteCrawlEdges).where(and55(...scope, outboundMatch)).orderBy(asc11(siteCrawlEdges.edgeKey)).limit(limit + 1).all();
@@ -84484,7 +84510,7 @@ async function technicalAeoRoutes(app, opts) {
84484
84510
  }).from(siteCrawlPages).where(and55(
84485
84511
  auditedWhere,
84486
84512
  isNotNull5(siteCrawlPages.auditScore),
84487
- lt10(siteCrawlPages.auditScore, 70)
84513
+ lt11(siteCrawlPages.auditScore, 70)
84488
84514
  )).orderBy(asc11(siteCrawlPages.auditScore), asc11(siteCrawlPages.nodeKey)).limit(LIVE_PAGE_HEALTH_CANDIDATE_LIMIT).all();
84489
84515
  const examples = candidates.flatMap((candidate) => {
84490
84516
  if (candidate.auditScore == null) return [];
@@ -84577,7 +84603,7 @@ async function technicalAeoRoutes(app, opts) {
84577
84603
  // ../api-routes/src/research.ts
84578
84604
  import crypto49 from "crypto";
84579
84605
  import { z as z5 } from "zod";
84580
- import { and as and56, count as count3, desc as desc29, eq as eq66, gte as gte17, lt as lt11, or as or15, sql as sql26 } from "drizzle-orm";
84606
+ import { and as and56, count as count3, desc as desc29, eq as eq66, gte as gte17, lt as lt12, or as or16, sql as sql26 } from "drizzle-orm";
84581
84607
  var sameLocation = (a, b) => a.label === b.label && a.city === b.city && a.region === b.region && a.country === b.country && a.timezone === b.timezone;
84582
84608
  var BATCH_RECEIPT_PREFIX = "__canonry_research_batch__:";
84583
84609
  async function researchRoutes(app, opts) {
@@ -84638,7 +84664,7 @@ async function researchRoutes(app, opts) {
84638
84664
  const used = tx.select({ value: count3() }).from(researchRuns).where(and56(
84639
84665
  eq66(researchRuns.projectId, project.id),
84640
84666
  gte17(researchRuns.createdAt, start),
84641
- lt11(researchRuns.createdAt, end),
84667
+ lt12(researchRuns.createdAt, end),
84642
84668
  sql26`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
84643
84669
  )).get()?.value ?? 0;
84644
84670
  if (used >= viewerDailyRunLimit) {
@@ -84693,7 +84719,7 @@ async function researchRoutes(app, opts) {
84693
84719
  const used = tx.select({ value: count3() }).from(researchRuns).where(and56(
84694
84720
  eq66(researchRuns.projectId, project.id),
84695
84721
  gte17(researchRuns.createdAt, start),
84696
- lt11(researchRuns.createdAt, end),
84722
+ lt12(researchRuns.createdAt, end),
84697
84723
  sql26`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
84698
84724
  )).get()?.value ?? 0;
84699
84725
  if (used + prepared.length > viewerDailyRunLimit) throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
@@ -84747,7 +84773,7 @@ async function researchRoutes(app, opts) {
84747
84773
  const cursor = request.query.cursor ? parseResearchCursor(request.query.cursor, project.id) : null;
84748
84774
  const rows = app.db.select().from(researchRuns).where(and56(
84749
84775
  eq66(researchRuns.projectId, project.id),
84750
- cursor ? or15(lt11(researchRuns.createdAt, cursor.createdAt), and56(eq66(researchRuns.createdAt, cursor.createdAt), lt11(researchRuns.id, cursor.id))) : void 0
84776
+ cursor ? or16(lt12(researchRuns.createdAt, cursor.createdAt), and56(eq66(researchRuns.createdAt, cursor.createdAt), lt12(researchRuns.id, cursor.id))) : void 0
84751
84777
  )).orderBy(desc29(researchRuns.createdAt), desc29(researchRuns.id)).limit(limit + 1).all();
84752
84778
  const runs2 = rows.slice(0, limit).map(serializeRun);
84753
84779
  const last = runs2.at(-1);
@@ -85924,11 +85950,11 @@ function analyzableRunPredicates(projectId) {
85924
85950
  eq68(runs.projectId, projectId),
85925
85951
  // Only this kind writes query_snapshots.
85926
85952
  eq68(runs.kind, RunKinds["answer-visibility"]),
85927
- or16(eq68(runs.status, RunStatuses.completed), eq68(runs.status, RunStatuses.partial)),
85953
+ or17(eq68(runs.status, RunStatuses.completed), eq68(runs.status, RunStatuses.partial)),
85928
85954
  // Defensive: RunCoordinator already skips probes before analyzeAndPersist
85929
85955
  // is called, but a future call site invoking it directly for a probe must
85930
85956
  // still not pollute the intelligence window.
85931
- ne11(runs.trigger, RunTriggers.probe)
85957
+ ne10(runs.trigger, RunTriggers.probe)
85932
85958
  ];
85933
85959
  }
85934
85960
  function readStoredGroundingSources(rawResponse) {
@@ -86514,9 +86540,9 @@ var IntelligenceService = class {
86514
86540
  and58(
86515
86541
  eq68(runs.projectId, projectId),
86516
86542
  eq68(runs.kind, RunKinds["answer-visibility"]),
86517
- or16(eq68(runs.status, "completed"), eq68(runs.status, "partial")),
86543
+ or17(eq68(runs.status, "completed"), eq68(runs.status, "partial")),
86518
86544
  // Defensive — see top of file.
86519
- ne11(runs.trigger, RunTriggers.probe)
86545
+ ne10(runs.trigger, RunTriggers.probe)
86520
86546
  )
86521
86547
  ).orderBy(desc30(runs.createdAt), desc30(runs.id)).limit((RECURRENCE_LOOKBACK_RUNS + 1) * ROWS_PER_GROUP_BUDGET).all();
86522
86548
  const recentGroups = groupRunsByCreatedAt(recentRunRows);