@canonry/canonry 5.28.1 → 5.29.1

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 (33) hide show
  1. package/assets/assets/{AuditHistoryPanel-CWd9UN6L.js → AuditHistoryPanel-B_H7nIp7.js} +1 -1
  2. package/assets/assets/{BacklinksPage-B2gPTzyE.js → BacklinksPage-rhms6z2S.js} +1 -1
  3. package/assets/assets/{HistoryPage-xtkG8SZN.js → HistoryPage-CWPETgrG.js} +1 -1
  4. package/assets/assets/MeasurementPropertyPage-D9-54Je9.js +1 -0
  5. package/assets/assets/ProjectPage-eNVzxsZW.js +8 -0
  6. package/assets/assets/{RunRow-DnSo7Nle.js → RunRow-DQPW7FIF.js} +1 -1
  7. package/assets/assets/{RunsPage-FAEuo4Ra.js → RunsPage-BOM_RkNi.js} +1 -1
  8. package/assets/assets/{SettingsPage-C4OZ1HB9.js → SettingsPage-BDnT6wtM.js} +1 -1
  9. package/assets/assets/SiteHealthSection-lcZA1XpM.js +4 -0
  10. package/assets/assets/{TrafficPage-D03S6xF6.js → TrafficPage-D4nGSfXx.js} +2 -2
  11. package/assets/assets/{TrafficSourceDetailPage-DBu6oFFK.js → TrafficSourceDetailPage-CvZcCbic.js} +1 -1
  12. package/assets/assets/{extract-error-message-BuH2CCaJ.js → extract-error-message-BJ3f0StM.js} +1 -1
  13. package/assets/assets/index-Cncchurw.js +86 -0
  14. package/assets/assets/{react-sigma_core.esm.min-CxwZsgxR.js → react-sigma_core.esm.min-c7qzyLLe.js} +1 -1
  15. package/assets/assets/{v2-overview-adapter-DTCKxzZT.js → v2-overview-adapter-Dsd6dWtE.js} +1 -1
  16. package/assets/index.html +1 -1
  17. package/dist/{chunk-DX2JUC5H.js → chunk-ASYPQYVV.js} +3 -3
  18. package/dist/{chunk-KVQHG3WB.js → chunk-FKL5EGOW.js} +6302 -5914
  19. package/dist/{chunk-EP4STEPI.js → chunk-N2SVFXOB.js} +164 -96
  20. package/dist/{chunk-ONTEFAA7.js → chunk-PTTVDWMN.js} +57 -3
  21. package/dist/{chunk-7RKCDOWO.js → chunk-RHZKOXDZ.js} +263 -190
  22. package/dist/{chunk-MPICT6V7.js → chunk-WL2SHCPU.js} +3 -3
  23. package/dist/{chunk-FNGITGDF.js → chunk-XWBQWAVE.js} +2 -2
  24. package/dist/cli.js +134 -123
  25. package/dist/{demo-server-HWWIMGD6.js → demo-server-VGUIDC5T.js} +24 -32
  26. package/dist/index.js +6 -6
  27. package/dist/{intelligence-service-6LDMVI66.js → intelligence-service-IQA4U3RA.js} +2 -2
  28. package/dist/mcp.js +4 -4
  29. package/package.json +12 -12
  30. package/assets/assets/MeasurementPropertyPage-DxIMU3_T.js +0 -1
  31. package/assets/assets/ProjectPage-CMAFZq_s.js +0 -8
  32. package/assets/assets/SiteHealthSection-BzMxlz8-.js +0 -4
  33. package/assets/assets/index-Ba-Zs45G.js +0 -86
@@ -61,6 +61,7 @@ import {
61
61
  GOOGLE_MARKETING_LIVE_READ_SCOPE,
62
62
  GOOGLE_MARKETING_STORED_SNAPSHOT_PAGE_MAX,
63
63
  GOOGLE_MARKETING_WRITE_SCOPE,
64
+ GaMoverChangeBases,
64
65
  GoogleAdsCampaignStatuses,
65
66
  GoogleAdsConnectionStates,
66
67
  GoogleAdsGoalConfigurationLevels,
@@ -92,6 +93,7 @@ import {
92
93
  MEASUREMENT_PORTFOLIO_TIE_NOTE,
93
94
  MEASUREMENT_PROPERTY_CITED_DOMAINS_LIMIT,
94
95
  MIN_DOMAIN_BRAND_KEY_LENGTH,
96
+ MIN_PCT_BASE,
95
97
  MIN_TREND_POINTS,
96
98
  MODEL_ATTRIBUTION_EVENT_LIMIT,
97
99
  MeasurementEvidenceShapes,
@@ -110,6 +112,7 @@ import {
110
112
  REPORT_VISIBILITY_COPY,
111
113
  RESEARCH_RUN_SCOPE,
112
114
  RUN_FILL_MAX_AGE_MS,
115
+ RatioUnits,
113
116
  ReportSectionIds,
114
117
  ResearchQueryStatuses,
115
118
  ResearchRunStatuses,
@@ -211,6 +214,7 @@ import {
211
214
  aggregateHarvestedQueries,
212
215
  aiReferralClassCounts,
213
216
  alreadyExists,
217
+ answerProseForMentions,
214
218
  apiKeyDtoSchema,
215
219
  apiKeyListDtoSchema,
216
220
  applyHarvestSemanticNovelty,
@@ -238,6 +242,7 @@ import {
238
242
  brandKeyFromText,
239
243
  brandLabelFromDomain,
240
244
  brandMetricsDtoSchema,
245
+ breakdownShares,
241
246
  buildHarvestAnchorTerms,
242
247
  buildMeasurementExecutionIdentity,
243
248
  buildMeasurementRunManifestV1,
@@ -351,8 +356,8 @@ import {
351
356
  formatIsoDate,
352
357
  formatIsoDateInTimeZone,
353
358
  formatNumber,
354
- formatRatio,
355
- formatWholePercent,
359
+ formatPercent,
360
+ formatSignedPercent,
356
361
  formatWindowCountDelta,
357
362
  ga4AiReferralDailyDtoSchema,
358
363
  ga4AiReferralHistoryEntrySchema,
@@ -361,9 +366,12 @@ import {
361
366
  ga4SocialReferralHistoryEntrySchema,
362
367
  ga4StatusDtoSchema,
363
368
  ga4SyncResponseDtoSchema,
369
+ gaAttributionTrendResponseSchema,
364
370
  gaMeasurementAnalysisDtoSchema,
365
371
  gaMeasurementAnalysisWindowSchema,
366
372
  gaMeasurementHostScopeSchema,
373
+ gaSocialReferralTrendResponseSchema,
374
+ gaTrafficResponseSchema,
367
375
  gateHarvestedSearchQueries,
368
376
  gbpAccountListResponseSchema,
369
377
  gbpAttributesListResponseSchema,
@@ -584,6 +592,7 @@ import {
584
592
  parseRunError,
585
593
  parseStoredMeasurementPlanAnyVersion,
586
594
  parseWindow,
595
+ percentOf,
587
596
  pickClusterRepresentative,
588
597
  prepareBrandMatchText,
589
598
  projectConfigSchema,
@@ -665,6 +674,7 @@ import {
665
674
  reportProviderRateLabel,
666
675
  reportQueryClassLabel,
667
676
  reportRateDeltaCopy,
677
+ reportRateDeltaValue,
668
678
  reportReferralRedirectNote,
669
679
  reportServerActivityAgencyOperatorHeaders,
670
680
  reportServerActivityAgencyTiles,
@@ -708,6 +718,7 @@ import {
708
718
  resultsClearResponseSchema,
709
719
  resultsExportDtoSchema,
710
720
  retryAfterDelayMs,
721
+ roundRatio,
711
722
  runCompletenessDtoSchema,
712
723
  runDetailDtoSchema,
713
724
  runDtoSchema,
@@ -734,6 +745,7 @@ import {
734
745
  segmentCrawlerHits,
735
746
  serializeRunError,
736
747
  settingsDtoSchema,
748
+ shareOf,
737
749
  shareOfVoiceReason,
738
750
  shareOfVoiceSummary,
739
751
  shiftIsoCalendarDate,
@@ -772,6 +784,7 @@ import {
772
784
  sourceBreakdownDtoSchema,
773
785
  sourceBreakdownQuerySchema,
774
786
  splitList,
787
+ stripCitationChips,
775
788
  sumInfraHits,
776
789
  summarizeCheckResults,
777
790
  summarizeRunUsage,
@@ -825,7 +838,7 @@ import {
825
838
  wordpressSchemaDeployResultDtoSchema,
826
839
  wordpressSchemaStatusResultDtoSchema,
827
840
  wordpressStatusDtoSchema
828
- } from "./chunk-KVQHG3WB.js";
841
+ } from "./chunk-FKL5EGOW.js";
829
842
 
830
843
  // src/intelligence-service.ts
831
844
  import { eq as eq79, desc as desc34, asc as asc14, and as and66, ne as ne13, or as or18, inArray as inArray29, gte as gte21, lte as lte18, isNull as isNull14, sql as sql29, exists } from "drizzle-orm";
@@ -9059,8 +9072,7 @@ function buildDrivers(input) {
9059
9072
  drivers.push(`${formatImpressions(input.gscImpressions)} GSC impressions`);
9060
9073
  }
9061
9074
  if (input.recentMissRate >= 0.5 && input.competitorCount > 0) {
9062
- const pct = Math.round(clamp01(input.recentMissRate) * 100);
9063
- drivers.push(`missed in ${pct}% of recent runs`);
9075
+ drivers.push(`missed in ${formatPercent(clamp01(input.recentMissRate))} of recent runs`);
9064
9076
  }
9065
9077
  if (input.action === "create" && input.position === null) {
9066
9078
  drivers.push("no existing page");
@@ -9443,8 +9455,8 @@ function buildCitationScorecard(snapshots, queryLookup) {
9443
9455
  }
9444
9456
  const providerRates = providerList.map((provider) => {
9445
9457
  const counts2 = providerCounts.get(provider) ?? { cited: 0, mentioned: 0, total: 0 };
9446
- const citationRate = counts2.total > 0 ? Math.round(counts2.cited / counts2.total * 100) : 0;
9447
- const mentionRate3 = counts2.total > 0 ? Math.round(counts2.mentioned / counts2.total * 100) : 0;
9458
+ const citationRate = percentOf(counts2.cited, counts2.total) ?? 0;
9459
+ const mentionRate3 = percentOf(counts2.mentioned, counts2.total) ?? 0;
9448
9460
  return {
9449
9461
  provider,
9450
9462
  citedCount: counts2.cited,
@@ -9485,8 +9497,9 @@ function compileCompetitiveSignalResolver(competitorDomains) {
9485
9497
  ...evidence.citedDomains ?? [],
9486
9498
  ...(evidence.groundingSources ?? []).map((source) => source.uri)
9487
9499
  ];
9488
- const answerDomains2 = evidence.answerDomains ?? extractDomainsFromText(evidence.answerText);
9489
- const mentionedBrandKeys = matchedAliasKeys(domainBrandMatcher, evidence.answerText);
9500
+ const prose = answerProseForMentions(evidence.answerText);
9501
+ const answerDomains2 = evidence.answerDomains ?? extractDomainsFromText(prose);
9502
+ const mentionedBrandKeys = matchedAliasKeys(domainBrandMatcher, prose);
9490
9503
  const citedCompetitorDomains = [];
9491
9504
  const mentionedCompetitorDomains = [];
9492
9505
  for (const identity of identities) {
@@ -9549,7 +9562,7 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
9549
9562
  else if (ratio >= 0.2) pressureLabel = "Moderate";
9550
9563
  else pressureLabel = "Low";
9551
9564
  }
9552
- const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
9565
+ const sharePct = percentOf(data.count, totalCitedSlots) ?? 0;
9553
9566
  const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
9554
9567
  return {
9555
9568
  domain,
@@ -9584,17 +9597,37 @@ function toBreakdown(tally, competitors2) {
9584
9597
  const perCompetitor = competitors2.map((c) => ({
9585
9598
  domain: c.domain,
9586
9599
  mentionSnapshots: tally.competitorCounts.get(c.domain) ?? 0,
9587
- shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((tally.competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
9600
+ shareOfCompetitiveTotal: percentOf(tally.competitorCounts.get(c.domain) ?? 0, competitorMentionSnapshots) ?? 0
9588
9601
  })).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1));
9589
9602
  return {
9590
9603
  projectMentionSnapshots: tally.projectMentionSnapshots,
9591
9604
  competitorMentionSnapshots,
9605
+ combinedMentionSnapshots: denom,
9592
9606
  perCompetitor,
9607
+ ranking: rankMentionShare(tally, competitors2, denom),
9593
9608
  snapshotsWithAnswerText: tally.snapshotsWithAnswerText,
9594
9609
  snapshotsTotal: tally.snapshotsTotal,
9595
- score: denom > 0 ? Math.round(tally.projectMentionSnapshots / denom * 100) : null
9610
+ score: percentOf(tally.projectMentionSnapshots, denom)
9596
9611
  };
9597
9612
  }
9613
+ function rankMentionShare(tally, competitors2, denom) {
9614
+ if (competitors2.length === 0 || denom === 0) return [];
9615
+ const project = {
9616
+ kind: "project",
9617
+ domain: null,
9618
+ mentionSnapshots: tally.projectMentionSnapshots,
9619
+ share: tally.projectMentionSnapshots / denom
9620
+ };
9621
+ const rivals = competitors2.map((c) => ({ domain: c.domain, mentionSnapshots: tally.competitorCounts.get(c.domain) ?? 0 })).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1)).map((c) => ({
9622
+ kind: "competitor",
9623
+ domain: c.domain,
9624
+ mentionSnapshots: c.mentionSnapshots,
9625
+ share: c.mentionSnapshots / denom
9626
+ }));
9627
+ const firstNotAbove = rivals.findIndex((row) => row.mentionSnapshots <= project.mentionSnapshots);
9628
+ const at = firstNotAbove === -1 ? rivals.length : firstNotAbove;
9629
+ return [...rivals.slice(0, at), project, ...rivals.slice(at)];
9630
+ }
9598
9631
  function buildMentionShare(snapshots, options) {
9599
9632
  const competitorMatchers = new Map(
9600
9633
  options.competitors.map((c) => [c.domain, compileBrandAliases(usableBrandAliases([...c.brandTokens]))])
@@ -9610,7 +9643,7 @@ function buildMentionShare(snapshots, options) {
9610
9643
  tally.snapshotsWithAnswerText++;
9611
9644
  if (snap.projectMentioned) tally.projectMentionSnapshots++;
9612
9645
  if (options.competitors.length === 0) continue;
9613
- const prepared = prepareBrandMatchText(text2);
9646
+ const prepared = prepareBrandMatchText(answerProseForMentions(text2));
9614
9647
  for (const competitor of options.competitors) {
9615
9648
  const matcher = competitorMatchers.get(competitor.domain);
9616
9649
  if (matcher && matcherMatchesText(matcher, prepared)) {
@@ -9691,7 +9724,7 @@ function buildMentionShare(snapshots, options) {
9691
9724
  value: "No mentions",
9692
9725
  delta: scopeLabel(scope, "No brand mentions in this run"),
9693
9726
  tone: "neutral",
9694
- description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
9727
+ description: describe({ scope, breakdown: headline, branded: brandedBreakdown }),
9695
9728
  tooltip,
9696
9729
  trend: [],
9697
9730
  scope,
@@ -9701,10 +9734,10 @@ function buildMentionShare(snapshots, options) {
9701
9734
  }
9702
9735
  return {
9703
9736
  label: "Mention Share",
9704
- value: `${score}`,
9737
+ value: formatPercent(headline.projectMentionSnapshots / denom),
9705
9738
  delta: scopeLabel(scope, `${headline.projectMentionSnapshots} of ${denom} brand mentions`),
9706
9739
  tone: mentionShareTone(score),
9707
- description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
9740
+ description: describe({ scope, breakdown: headline, branded: brandedBreakdown }),
9708
9741
  tooltip,
9709
9742
  trend: [],
9710
9743
  progress: score,
@@ -9722,7 +9755,7 @@ function mentionShareTone(score) {
9722
9755
  return "negative";
9723
9756
  }
9724
9757
  function describe(parts) {
9725
- const { scope, score, breakdown: breakdown2, branded } = parts;
9758
+ const { scope, breakdown: breakdown2, branded } = parts;
9726
9759
  const { projectMentionSnapshots, competitorMentionSnapshots, perCompetitor } = breakdown2;
9727
9760
  const where = scope === "non-brand" ? "on non-brand queries" : "across your tracked queries";
9728
9761
  const brandedNote = branded.snapshotsWithAnswerText > 0 && branded.score !== null ? ` Separately, you are named in ${branded.projectMentionSnapshots} of ${branded.snapshotsWithAnswerText} answers to queries that contain your name.` : "";
@@ -9734,10 +9767,11 @@ function describe(parts) {
9734
9767
  }
9735
9768
  const top = perCompetitor[0];
9736
9769
  const total = projectMentionSnapshots + competitorMentionSnapshots;
9770
+ const share = formatPercent(projectMentionSnapshots / total);
9737
9771
  if (!top) {
9738
- return `${score}% of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
9772
+ return `${share} of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
9739
9773
  }
9740
- return `${score}% of brand mentions ${where} are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).${brandedNote}`;
9774
+ return `${share} of brand mentions ${where} are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).${brandedNote}`;
9741
9775
  }
9742
9776
 
9743
9777
  // ../intelligence/src/share-of-voice-frame.ts
@@ -9754,7 +9788,7 @@ function buildShareOfVoiceFrame(input) {
9754
9788
  basis,
9755
9789
  reason,
9756
9790
  availability: reason === null ? "measured" : "not-measured",
9757
- score: reason === null ? Math.round(input.projectMentions / denominator * 1e3) / 10 : null,
9791
+ score: reason === null ? percentOf(input.projectMentions, denominator) : null,
9758
9792
  denominator,
9759
9793
  competitorMentions,
9760
9794
  domains
@@ -9875,7 +9909,7 @@ function buildCompetitorLandscapeHistory(options) {
9875
9909
  for (const candidate of frozen.values()) putCandidate(eligibleCandidates, candidate);
9876
9910
  combined = combineMatchers([...eligibleCandidates.values()].map((candidate) => candidate.matcher));
9877
9911
  }
9878
- const matchedKeys = matchedAliasKeys(combined, text2);
9912
+ const matchedKeys = matchedAliasKeys(combined, answerProseForMentions(text2));
9879
9913
  if (matchedKeys.size > 0) {
9880
9914
  for (const candidate of eligibleCandidates.values()) {
9881
9915
  for (const key of candidate.matcher.keys) {
@@ -10058,7 +10092,7 @@ function finalizeRow(row, answeredResults, mentionCredits, competitive) {
10058
10092
  surfaceClass: row.surfaceClass,
10059
10093
  pinned: row.pinned,
10060
10094
  mentionCount: row.mentionCount,
10061
- shareOfVoice: competitive ? mentionCredits > 0 ? roundPercentage(row.mentionCount / mentionCredits * 100) : null : null,
10095
+ shareOfVoice: competitive ? percentOf(row.mentionCount, mentionCredits) : null,
10062
10096
  citationCount: row.citationCount,
10063
10097
  answeredResults,
10064
10098
  firstSeenAt: row.firstSeenAt,
@@ -10066,9 +10100,6 @@ function finalizeRow(row, answeredResults, mentionCredits, competitive) {
10066
10100
  sampleUrls: [...row.sampleUrls]
10067
10101
  };
10068
10102
  }
10069
- function roundPercentage(value) {
10070
- return Math.round(value * 10) / 10;
10071
- }
10072
10103
  function compareCompetitiveRows(a, b) {
10073
10104
  return b.mentionCount - a.mentionCount || b.citationCount - a.citationCount || a.domain.localeCompare(b.domain);
10074
10105
  }
@@ -10097,7 +10128,7 @@ function toSection(tally) {
10097
10128
  else if (ratio >= 0.2) pressureLabel = "Moderate";
10098
10129
  else pressureLabel = "Low";
10099
10130
  }
10100
- const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : null;
10131
+ const sharePct = percentOf(data.count, totalMentionedSlots);
10101
10132
  return {
10102
10133
  domain,
10103
10134
  mentionCount: data.count,
@@ -10186,7 +10217,7 @@ function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains, topDo
10186
10217
  category,
10187
10218
  label,
10188
10219
  count: count4,
10189
- sharePct: totalCitations > 0 ? Math.round(count4 / totalCitations * 100) : 0
10220
+ sharePct: percentOf(count4, totalCitations) ?? 0
10190
10221
  })).sort((a, b) => b.count - a.count);
10191
10222
  const topDomains = [...domainCounts.entries()].map(([domain, count4]) => ({
10192
10223
  domain,
@@ -10378,13 +10409,13 @@ function buildVisibilityScore(snapshots, options) {
10378
10409
  }
10379
10410
  const totalCount = queryCited.size;
10380
10411
  const citedCount = [...queryCited.values()].filter(Boolean).length;
10381
- const score = totalCount > 0 ? Math.round(citedCount / totalCount * 100) : 0;
10412
+ const score = percentOf(citedCount, totalCount) ?? 0;
10382
10413
  const runProviders = new Set(snapshots.map((s) => s.provider));
10383
10414
  const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
10384
10415
  const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
10385
10416
  return {
10386
10417
  label: "Citation Coverage",
10387
- value: `${score}`,
10418
+ value: formatPercent(citedCount / totalCount),
10388
10419
  delta: `${citedCount} of ${totalCount} queries cited`,
10389
10420
  tone: isPartialProviderRun ? "caution" : scoreTone(score),
10390
10421
  description: `${citedCount} of ${totalCount} tracked queries found your domain in at least one AI answer engine.`,
@@ -10416,13 +10447,13 @@ function buildMentionCoverage(snapshots, options) {
10416
10447
  }
10417
10448
  const totalCount = queryMentioned.size;
10418
10449
  const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
10419
- const score = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
10450
+ const score = percentOf(mentionedCount, totalCount) ?? 0;
10420
10451
  const runProviders = new Set(snapshots.map((s) => s.provider));
10421
10452
  const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
10422
10453
  const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
10423
10454
  return {
10424
10455
  label: "Mention Coverage",
10425
- value: `${score}`,
10456
+ value: formatPercent(mentionedCount / totalCount),
10426
10457
  delta: `${mentionedCount} of ${totalCount} queries mentioned`,
10427
10458
  tone: isPartialProviderRun ? "caution" : scoreTone(score),
10428
10459
  description: `${mentionedCount} of ${totalCount} tracked queries had your brand or domain in the AI answer text.`,
@@ -10468,7 +10499,7 @@ function buildGapQueryScore(snapshots) {
10468
10499
  description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive citation gaps detected in the latest visibility run.",
10469
10500
  tooltip,
10470
10501
  trend: [],
10471
- progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
10502
+ progress: percentOf(gapCount, totalCount) ?? 0
10472
10503
  };
10473
10504
  }
10474
10505
  function buildMentionGapScore(snapshots) {
@@ -10505,7 +10536,7 @@ function buildMentionGapScore(snapshots) {
10505
10536
  description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} mention competitors but never your brand.` : "No competitive mention gaps detected in the latest visibility run.",
10506
10537
  tooltip,
10507
10538
  trend: [],
10508
- progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
10539
+ progress: percentOf(gapCount, totalCount) ?? 0
10509
10540
  };
10510
10541
  }
10511
10542
 
@@ -10586,7 +10617,7 @@ function buildProviderScores(snapshots) {
10586
10617
  ).map(({ provider, model, cited, total }) => ({
10587
10618
  provider,
10588
10619
  model,
10589
- score: total > 0 ? Math.round(cited / total * 100) : 0,
10620
+ score: percentOf(cited, total) ?? 0,
10590
10621
  cited,
10591
10622
  total
10592
10623
  }));
@@ -10608,9 +10639,9 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
10608
10639
  }
10609
10640
  const totalCount = queryCited.size;
10610
10641
  const citedCount = [...queryCited.values()].filter(Boolean).length;
10611
- const citationRate = totalCount > 0 ? Math.round(citedCount / totalCount * 100) : 0;
10642
+ const citationRate = percentOf(citedCount, totalCount) ?? 0;
10612
10643
  const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
10613
- const mentionRate3 = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
10644
+ const mentionRate3 = percentOf(mentionedCount, totalCount) ?? 0;
10614
10645
  return {
10615
10646
  runId: run.id,
10616
10647
  createdAt: run.createdAt,
@@ -10642,7 +10673,8 @@ function buildProviderTrends(runs2, snapshotsByRunId, limit = 12) {
10642
10673
  }
10643
10674
  for (const key of keys) {
10644
10675
  const queryMap = perKey.get(key);
10645
- const rate2 = queryMap && queryMap.size > 0 ? Math.round([...queryMap.values()].filter(Boolean).length / queryMap.size * 100) : 0;
10676
+ const cited = queryMap ? [...queryMap.values()].filter(Boolean).length : 0;
10677
+ const rate2 = percentOf(cited, queryMap?.size ?? 0) ?? 0;
10646
10678
  result.get(key).push({ rate: rate2, createdAt: run.createdAt });
10647
10679
  }
10648
10680
  }
@@ -10713,7 +10745,7 @@ function formatImpressions2(value) {
10713
10745
 
10714
10746
  // ../intelligence/src/smoothed-delta.ts
10715
10747
  var SMOOTHED_RUN_DELTA_MAX_WINDOW = 3;
10716
- function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WINDOW) {
10748
+ function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WINDOW, ratioUnit) {
10717
10749
  if (points.length < 2) return null;
10718
10750
  const window = Math.min(maxWindow, Math.floor(points.length / 2));
10719
10751
  const tail = points.slice(-window);
@@ -10721,8 +10753,9 @@ function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WI
10721
10753
  const sum = (arr) => arr.reduce((s, p) => s + valueFn(p), 0);
10722
10754
  const currentAvg = sum(tail) / tail.length;
10723
10755
  const priorAvg = sum(prior) / prior.length;
10724
- const current = roundTo1Decimal(currentAvg);
10725
- const prior_ = roundTo1Decimal(priorAvg);
10756
+ const round = (value) => ratioUnit ? roundRatio(value, ratioUnit) : roundTo1Decimal(value);
10757
+ const current = round(currentAvg);
10758
+ const prior_ = round(priorAvg);
10726
10759
  return {
10727
10760
  current,
10728
10761
  prior: prior_,
@@ -10819,7 +10852,7 @@ function analyzeGbp(signals) {
10819
10852
  ...base,
10820
10853
  type: "gbp-metric-drop",
10821
10854
  severity: abs >= GBP_METRIC_SEVERE_PCT ? "high" : "medium",
10822
- title: `${loc.displayName}: ${metricLabel(worstMetric.metric)} down ${abs}% week-over-week`,
10855
+ title: `${loc.displayName}: ${metricLabel(worstMetric.metric)} down ${formatPercent(abs, RatioUnits.percent)} week-over-week`,
10823
10856
  recommendation: {
10824
10857
  action: "Investigate the local-visibility drop (profile changes, category edits, new competition)",
10825
10858
  reason: `${metricLabel(worstMetric.metric)} fell from ${worstMetric.prior} to ${worstMetric.recent} (last 7d vs the prior 7d).`
@@ -10833,7 +10866,7 @@ function analyzeGbp(signals) {
10833
10866
  ...base,
10834
10867
  type: "gbp-keyword-drop",
10835
10868
  severity: worstKeyword.dropPct >= GBP_KEYWORD_SEVERE_PCT && !actionsHeldUp(loc) ? "high" : "medium",
10836
- title: `${loc.displayName}: "${worstKeyword.keyword}" impressions down ${worstKeyword.dropPct}% month-over-month${window}`,
10869
+ title: `${loc.displayName}: "${worstKeyword.keyword}" impressions down ${formatPercent(worstKeyword.dropPct, RatioUnits.percent)} month-over-month${window}`,
10837
10870
  recommendation: {
10838
10871
  action: "Check whether the property still ranks for this local search term and refresh the profile",
10839
10872
  reason: `Search-keyword impressions for "${worstKeyword.keyword}" fell from ${worstKeyword.prior} to ${worstKeyword.recent} month-over-month.`
@@ -10867,7 +10900,7 @@ function pickWorstKeywordDrop(loc) {
10867
10900
  if (point.recent == null || point.prior == null || point.prior < GBP_KEYWORD_MIN_BASELINE) continue;
10868
10901
  const changeRatio = relativeChangeRatio(point.recent, point.prior);
10869
10902
  if (changeRatio === null) continue;
10870
- const dropPct = Math.round(-changeRatio * 100);
10903
+ const dropPct = roundRatio(-changeRatio * 100, RatioUnits.percent);
10871
10904
  if (dropPct < GBP_KEYWORD_DROP_PCT) continue;
10872
10905
  if (!worst || dropPct > worst.dropPct) {
10873
10906
  worst = { keyword: point.keyword, dropPct, recent: point.recent, prior: point.prior };
@@ -13288,8 +13321,8 @@ function aliasMatchesAt(textWords, aliasWords, start) {
13288
13321
  }
13289
13322
  return true;
13290
13323
  }
13291
- function containsAnyAlias(answerText, aliases) {
13292
- const textWords = words(answerText);
13324
+ function containsAnyAlias(prose, aliases) {
13325
+ const textWords = words(prose);
13293
13326
  const candidates = aliases.map(words).filter((alias2) => alias2.length > 0);
13294
13327
  for (let start = 0; start < textWords.length; start++) {
13295
13328
  if (candidates.some((alias2) => aliasMatchesAt(textWords, alias2, start))) return true;
@@ -13431,6 +13464,7 @@ function prepareReport(input, options = {}, detailedEvidence = true) {
13431
13464
  if (!source.complete) evidenceIncomplete.add(observation.id);
13432
13465
  observationsBySlot.set(slot.id, {
13433
13466
  input: observation,
13467
+ mentionText: answerProseForMentions(observation.answerText),
13434
13468
  slot,
13435
13469
  bridged: isBridged,
13436
13470
  historical: source.historical,
@@ -13466,7 +13500,7 @@ function prepareReport(input, options = {}, detailedEvidence = true) {
13466
13500
  const source = sourceAttribution(url);
13467
13501
  return source.classification === "matched" ? source.matchedTargetIds : [];
13468
13502
  }));
13469
- const mentions = resolveMentionIdentity(observation.input.answerText, input.targets, aliasesByFirstWord, citedTargetIds, ambiguityPatterns);
13503
+ const mentions = resolveMentionIdentity(observation.mentionText, input.targets, aliasesByFirstWord, citedTargetIds, ambiguityPatterns);
13470
13504
  observation.mentionedTargetIds = mentions.mentioned;
13471
13505
  observation.unknownMentionTargetIds = mentions.unknown;
13472
13506
  observation.citedTargetIds = citedTargetIds;
@@ -13645,7 +13679,7 @@ function buildSovForSlots(slots, competitors2, projectBrandNames, projectDomain,
13645
13679
  return { domain: row.domain, own: row.own, presentIn: null, of: null, reason: "aliasless" };
13646
13680
  }
13647
13681
  const presentIn = selected.filter((slot) => {
13648
- const answer = prepared.observationsBySlot.get(slot.id)?.input.answerText;
13682
+ const answer = prepared.observationsBySlot.get(slot.id)?.mentionText;
13649
13683
  return answer !== null && answer !== void 0 && containsAnyAlias(answer, row.aliases);
13650
13684
  }).length;
13651
13685
  return { domain: row.domain, own: row.own, presentIn, of: selected.length };
@@ -13842,7 +13876,7 @@ function targetMentionRate(targets, slots, answered2, indexes) {
13842
13876
  }
13843
13877
  function presenceIn(slots, aliases, prepared) {
13844
13878
  return slots.filter((slot) => {
13845
- const answer = prepared.observationsBySlot.get(slot.id)?.input.answerText;
13879
+ const answer = prepared.observationsBySlot.get(slot.id)?.mentionText;
13846
13880
  return answer !== null && answer !== void 0 && containsAnyAlias(answer, aliases);
13847
13881
  }).length;
13848
13882
  }
@@ -16206,7 +16240,7 @@ function buildMentionShareInputs(opts) {
16206
16240
  snapshots: opts.snapshots.map((snap) => {
16207
16241
  const queryText = (snap.queryId ? opts.queryTextById?.get(snap.queryId) : void 0) ?? snap.queryText ?? null;
16208
16242
  const cachedAnswerDomains = !snap.answerText || !opts.answerDomainsByText ? void 0 : opts.answerDomainsByText.get(snap.answerText) ?? (() => {
16209
- const domains = extractDomainsFromText(snap.answerText);
16243
+ const domains = extractDomainsFromText(answerProseForMentions(snap.answerText));
16210
16244
  opts.answerDomainsByText.set(snap.answerText, domains);
16211
16245
  return domains;
16212
16246
  })();
@@ -25299,7 +25333,10 @@ function frozenCompetitorMatchers(competitors2) {
25299
25333
  function exactCompetitorMatchers(plan) {
25300
25334
  return frozenCompetitorMatchers(plan.groups.flatMap((group) => group.competitors));
25301
25335
  }
25302
- function competitorSignals(snapshot, matchers, preparedAnswerText = prepareBrandMatchText(snapshot.answerText)) {
25336
+ function preparedAnswerProse(answerText) {
25337
+ return prepareBrandMatchText(answerProseForMentions(answerText));
25338
+ }
25339
+ function competitorSignals(snapshot, matchers, preparedAnswerText = preparedAnswerProse(snapshot.answerText)) {
25303
25340
  const mentioned = preparedAnswerText === null ? [] : [...matchers].filter(([, matcher]) => matcherMatchesText(matcher, preparedAnswerText)).map(([domain]) => domain);
25304
25341
  const citedDomains = new Set(snapshot.citedDomains.map((value) => {
25305
25342
  try {
@@ -25517,7 +25554,7 @@ function frozenSimpleRun(run, definition, snapshots, includeEvidence = true) {
25517
25554
  }
25518
25555
  const slotId = `slot:simple:${query.queryId}:${provider}`;
25519
25556
  if (!slotKeys.has(slotId)) throw new Error(`Frozen simple definition has no slot for stored snapshot ${snapshot.id}`);
25520
- const preparedAnswerText = prepareBrandMatchText(snapshot.answerText);
25557
+ const preparedAnswerText = preparedAnswerProse(snapshot.answerText);
25521
25558
  const mentioned = snapshot.answerText !== null ? matcherMatchesText(matcher, preparedAnswerText) : snapshot.answerMentioned === true;
25522
25559
  const competitorSignalsForSnapshot = !includeEvidence || competitors2 === null ? { mentioned: [], cited: [] } : competitorSignals(snapshot, competitors2, preparedAnswerText);
25523
25560
  observations.push({
@@ -28627,6 +28664,7 @@ async function analyticsRoutes(app) {
28627
28664
  byProvider: {},
28628
28665
  trend: "stable",
28629
28666
  mentionTrend: "stable",
28667
+ windowChange: { citationRate: null, mentionRate: null, mentionShare: null },
28630
28668
  queryChanges: [],
28631
28669
  basketChanges: [],
28632
28670
  executionIdentityChanges: [],
@@ -28839,6 +28877,7 @@ async function analyticsRoutes(app) {
28839
28877
  }
28840
28878
  const trend = computeTrend(buckets, "citationRate");
28841
28879
  const mentionTrend = computeTrend(buckets, "mentionRate");
28880
+ const windowChange = computeWindowChange(buckets);
28842
28881
  const queryChanges = computeQueryChanges(projectQueries, cutoff);
28843
28882
  const basketChanges = basketRevisions.slice(1).map((rev, i) => {
28844
28883
  const previous = new Set(basketRevisions[i].members);
@@ -28860,7 +28899,7 @@ async function analyticsRoutes(app) {
28860
28899
  }
28861
28900
  previousExecutionChecksum = identity.checksum;
28862
28901
  }
28863
- return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
28902
+ return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, windowChange, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
28864
28903
  });
28865
28904
  app.get("/projects/:name/analytics/gaps", async (request, reply) => {
28866
28905
  const project = resolveProject(app.db, request.params.name);
@@ -28939,9 +28978,10 @@ async function analyticsRoutes(app) {
28939
28978
  if (match) competitorsCiting.add(match);
28940
28979
  }
28941
28980
  if (!s.answerText) continue;
28981
+ const prose = prepareBrandMatchText(answerProseForMentions(s.answerText));
28942
28982
  for (const competitor of competitorDomains) {
28943
28983
  const matcher = competitorMatchers.get(competitor);
28944
- if (matcher && matcherMatchesText(matcher, s.answerText)) competitorsMentioned.add(competitor);
28984
+ if (matcher && matcherMatchesText(matcher, prose)) competitorsMentioned.add(competitor);
28945
28985
  }
28946
28986
  }
28947
28987
  const cons = consistencyMap.get(queryId);
@@ -29328,7 +29368,7 @@ function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors, cla
29328
29368
  scope: result.scope,
29329
29369
  // A project-only denominator is recognition evidence, not competitive
29330
29370
  // share. Preserve the count but leave the rate undefined without a frame.
29331
- rate: mentionShareCompetitors.length > 0 && denominator > 0 ? round4(projectMentionSnapshots / denominator) : null,
29371
+ rate: mentionShareCompetitors.length > 0 && denominator > 0 ? roundRatio(projectMentionSnapshots / denominator, RatioUnits.fraction) : null,
29332
29372
  projectMentionSnapshots,
29333
29373
  competitorMentionSnapshots
29334
29374
  };
@@ -29358,6 +29398,20 @@ function pooledRate(buckets, rateKey) {
29358
29398
  }
29359
29399
  return denominator > 0 ? numerator / denominator : 0;
29360
29400
  }
29401
+ function windowRateChange(rates) {
29402
+ const first = rates[0];
29403
+ const latest = rates.at(-1);
29404
+ if (rates.length < 2 || first === void 0 || latest === void 0) return null;
29405
+ return { first, latest, delta: roundRatio(latest - first, RatioUnits.fraction) };
29406
+ }
29407
+ function computeWindowChange(buckets) {
29408
+ const measured = buckets.filter((b) => b.total > 0);
29409
+ return {
29410
+ citationRate: windowRateChange(measured.map((b) => b.citationRate)),
29411
+ mentionRate: windowRateChange(measured.map((b) => b.mentionRate)),
29412
+ mentionShare: windowRateChange(buckets.flatMap((b) => b.mentionShare.rate === null ? [] : [b.mentionShare.rate]))
29413
+ };
29414
+ }
29361
29415
  function computeTrend(buckets, rateKey) {
29362
29416
  const nonEmpty = buckets.filter((b) => b.total > 0);
29363
29417
  if (nonEmpty.length < 2) return "stable";
@@ -29371,9 +29425,6 @@ function computeTrend(buckets, rateKey) {
29371
29425
  if (diff < -0.05) return "declining";
29372
29426
  return "stable";
29373
29427
  }
29374
- function round4(ratio) {
29375
- return Math.round(ratio * 1e4) / 1e4;
29376
- }
29377
29428
  function bumpDomain(map, domain, category, label, surfaceClass) {
29378
29429
  const existing = map.get(domain);
29379
29430
  if (existing) existing.count += 1;
@@ -29388,8 +29439,8 @@ function buildRankedList(domains, limit, answerTotal, answersWithSources) {
29388
29439
  const entries = shownEntries.map((d) => ({
29389
29440
  domain: d.domain,
29390
29441
  count: d.count,
29391
- percentage: totalCitedSlots > 0 ? round4(d.count / totalCitedSlots) : 0,
29392
- answerShare: answerTotal > 0 ? round4(d.count / answerTotal) : 0,
29442
+ percentage: totalCitedSlots > 0 ? roundRatio(d.count / totalCitedSlots, RatioUnits.fraction) : 0,
29443
+ answerShare: answerTotal > 0 ? roundRatio(d.count / answerTotal, RatioUnits.fraction) : 0,
29393
29444
  category: d.category,
29394
29445
  label: d.label,
29395
29446
  surfaceClass: d.surfaceClass
@@ -29406,7 +29457,7 @@ function buildRankedList(domains, limit, answerTotal, answersWithSources) {
29406
29457
  surfaceClass,
29407
29458
  label: surfaceClassLabel(surfaceClass),
29408
29459
  count: v.count,
29409
- percentage: totalCitedSlots > 0 ? round4(v.count / totalCitedSlots) : 0,
29460
+ percentage: totalCitedSlots > 0 ? roundRatio(v.count / totalCitedSlots, RatioUnits.fraction) : 0,
29410
29461
  domainCount: v.domainCount
29411
29462
  })).sort((a, b) => b.count - a.count || a.surfaceClass.localeCompare(b.surfaceClass));
29412
29463
  return {
@@ -31610,7 +31661,7 @@ function renderExecutiveSummary(report) {
31610
31661
  </div>
31611
31662
  <div class="hero-proof">
31612
31663
  <div class="mini-label">${copy.proofTiles.mentionCoverage}</div>
31613
- <div class="mini-value">${s.mentionRate}%</div>
31664
+ <div class="mini-value">${formatPercent(s.mentionRate, RatioUnits.percent)}</div>
31614
31665
  <div class="mini-copy">${escapeHtml2(headline.mentionedFragment)}</div>
31615
31666
  </div>
31616
31667
  <div class="hero-proof">
@@ -31623,12 +31674,12 @@ function renderExecutiveSummary(report) {
31623
31674
  const metrics = [
31624
31675
  {
31625
31676
  label: copy.tiles.citationRate,
31626
- value: `${s.citationRate}%`,
31677
+ value: formatPercent(s.citationRate, RatioUnits.percent),
31627
31678
  delta: `<span class="tone-${headline.trendTone}">${headline.trendLabel}</span> \xB7 ${headline.citedFragment} \xB7 ${headline.providerCountLabel}`
31628
31679
  },
31629
31680
  {
31630
31681
  label: copy.tiles.mentionRate,
31631
- value: `${s.mentionRate}%`,
31682
+ value: formatPercent(s.mentionRate, RatioUnits.percent),
31632
31683
  delta: headline.mentionedFragment
31633
31684
  },
31634
31685
  {
@@ -31684,11 +31735,10 @@ function renderRateDeltaTile(label, delta, unit) {
31684
31735
  if (!delta) {
31685
31736
  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>`;
31686
31737
  }
31687
- const valueSuffix = unit === "%" ? "%" : "";
31688
31738
  const deltaText = reportRateDeltaCopy(delta, unit);
31689
31739
  return `<div class="metric">
31690
31740
  <div class="label">${escapeHtml2(label)}</div>
31691
- <div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
31741
+ <div class="value ${deltaToneClass(delta.direction)}">${reportRateDeltaValue(delta, unit)} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
31692
31742
  <div class="delta">${deltaText}</div>
31693
31743
  </div>`;
31694
31744
  }
@@ -31711,8 +31761,8 @@ function renderProviderMovements(movements, audience) {
31711
31761
  const rows = meaningful.map((m) => {
31712
31762
  return `<tr>
31713
31763
  <td>${escapeHtml2(isClient ? reportProviderDisplayName(m.provider) : m.provider)}</td>
31714
- <td class="numeric">${m.prior}%</td>
31715
- <td class="numeric">${m.current}%</td>
31764
+ <td class="numeric">${formatPercent(m.prior, RatioUnits.percent)}</td>
31765
+ <td class="numeric">${formatPercent(m.current, RatioUnits.percent)}</td>
31716
31766
  <td class="numeric ${deltaToneClass(m.direction)}">${reportMovementChangeCopy(m)}</td>
31717
31767
  </tr>`;
31718
31768
  }).join("");
@@ -31905,7 +31955,7 @@ function renderCompetitorLandscape(report) {
31905
31955
  <td><span class="badge tone-${tone}">${escapeHtml2(c.pressureLabel)}</span></td>
31906
31956
  <td class="numeric">${c.citationCount} / ${c.totalCount}</td>
31907
31957
  <td class="numeric">${mentionCount} / ${mentionTotal}</td>
31908
- <td class="numeric">${c.sharePct}%</td>
31958
+ <td class="numeric">${formatPercent(c.sharePct, RatioUnits.percent)}</td>
31909
31959
  <td>${escapeHtml2(reportTruncatedList(c.citedQueries, 5))}${pagesDisclosure}</td>
31910
31960
  </tr>`;
31911
31961
  }).join("");
@@ -32016,7 +32066,7 @@ function renderAiSourceOrigin(report) {
32016
32066
  `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
32017
32067
  );
32018
32068
  }
32019
- function renderLineChart(points, color, title, height = 200) {
32069
+ function renderLineChart(points, color, title, height = 200, formatValue = formatNumber) {
32020
32070
  if (points.length === 0) return "";
32021
32071
  const width = 600;
32022
32072
  const padX = 32;
@@ -32040,8 +32090,8 @@ function renderLineChart(points, color, title, height = 200) {
32040
32090
  <h3>${escapeHtml2(title)}</h3>
32041
32091
  <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml2(reportLineChartLabel(title))}">
32042
32092
  <line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
32043
- <text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatNumber(max2)}</text>
32044
- <text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">0</text>
32093
+ <text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatValue(max2)}</text>
32094
+ <text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatValue(0)}</text>
32045
32095
  <path d="${path8}" stroke="${color}" stroke-width="2" fill="none" />
32046
32096
  ${dots}
32047
32097
  ${xLabels}
@@ -32062,7 +32112,7 @@ function renderGsc(report) {
32062
32112
  <td>${escapeHtml2(q.query)}</td>
32063
32113
  <td class="numeric">${formatNumber(q.clicks)}</td>
32064
32114
  <td class="numeric">${formatNumber(q.impressions)}</td>
32065
- <td class="numeric">${formatRatio(q.ctr)}</td>
32115
+ <td class="numeric">${formatPercent(q.ctr)}</td>
32066
32116
  <td class="numeric">${q.avgPosition.toFixed(1)}</td>
32067
32117
  <td><span class="badge tone-neutral">${escapeHtml2(q.category)}</span></td>
32068
32118
  </tr>`).join("");
@@ -32100,7 +32150,7 @@ function renderGsc(report) {
32100
32150
  `<div class="metric-grid">
32101
32151
  <div class="metric"><div class="label">${copy.tiles.clicks}</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
32102
32152
  <div class="metric"><div class="label">${copy.tiles.impressions}</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
32103
- <div class="metric"><div class="label">${copy.tiles.ctr}</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
32153
+ <div class="metric"><div class="label">${copy.tiles.ctr}</div><div class="value">${formatPercent(gsc.ctr)}</div></div>
32104
32154
  <div class="metric"><div class="label">${copy.tiles.position}</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
32105
32155
  </div>
32106
32156
  ${trendChart}
@@ -32448,7 +32498,7 @@ function renderIndexingHealth(report) {
32448
32498
  `<div class="metric-grid">
32449
32499
  <div class="metric"><div class="label">${copy.tiles.indexed}</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
32450
32500
  <div class="metric"><div class="label">${copy.tiles.total}</div><div class="value">${formatNumber(ih.total)}</div></div>
32451
- <div class="metric"><div class="label">${copy.tiles.share}</div><div class="value">${ih.indexedPct}%</div></div>
32501
+ <div class="metric"><div class="label">${copy.tiles.share}</div><div class="value">${formatPercent(ih.indexedPct, RatioUnits.percent)}</div></div>
32452
32502
  </div>
32453
32503
  <div class="chart-card">
32454
32504
  <h3>${copy.coverageHeading}</h3>
@@ -32476,12 +32526,13 @@ function renderCitationsTrend(report) {
32476
32526
  trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
32477
32527
  COLORS.positive,
32478
32528
  copy.chartTitle,
32479
- 220
32529
+ 220,
32530
+ (value) => formatPercent(value, RatioUnits.percent)
32480
32531
  );
32481
32532
  const rows = trend.map((t) => `
32482
32533
  <tr>
32483
32534
  <td>${formatDate(t.date)}</td>
32484
- <td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
32535
+ <td class="numeric">${formatPercent(t.citationRate, RatioUnits.percent)} <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
32485
32536
  <td>${escapeHtml2(reportTrendProviderRates(t.providerRates))}</td>
32486
32537
  </tr>`).join("");
32487
32538
  const [checkHeader, citedQueriesHeader, engineRatesHeader] = copy.breakdownHeaders;
@@ -32668,7 +32719,7 @@ function renderClientSummary(report) {
32668
32719
  const sc = report.citationScorecard;
32669
32720
  const copy = REPORT_SECTION_COPY["client-summary"];
32670
32721
  const totalQ = s.totalQueryCount ?? 0;
32671
- const heroNumber = totalQ > 0 ? `${s.mentionRate}%` : "\u2014";
32722
+ const heroNumber = totalQ > 0 ? formatPercent(s.mentionRate, RatioUnits.percent) : "\u2014";
32672
32723
  const heroSentence = reportClientHeroSentence(totalQ, s.mentionedQueryCount);
32673
32724
  const trend = reportClientTrendCopy(report.whatsChanged.mentionRate);
32674
32725
  const heroTrend = trend ? `<p class="client-hero-trend tone-${trend.tone}"><span style="margin-right:6px;">${trend.arrow}</span>${escapeHtml2(trend.text)}</p>` : "";
@@ -32682,12 +32733,12 @@ function renderClientSummary(report) {
32682
32733
  const tiles = `<div class="client-metric-grid">
32683
32734
  <div class="client-metric-tile">
32684
32735
  <div class="label">${copy.tiles.mentioned}</div>
32685
- <div class="value">${s.mentionRate}%</div>
32736
+ <div class="value">${formatPercent(s.mentionRate, RatioUnits.percent)}</div>
32686
32737
  <div class="subtitle">${reportClientMentionedSubtitle(s.mentionedQueryCount, totalQ)}</div>
32687
32738
  </div>
32688
32739
  <div class="client-metric-tile">
32689
32740
  <div class="label">${copy.tiles.cited}</div>
32690
- <div class="value">${s.citationRate}%</div>
32741
+ <div class="value">${formatPercent(s.citationRate, RatioUnits.percent)}</div>
32691
32742
  <div class="subtitle">${reportClientCitedSubtitle(s.citedQueryCount, totalQ)}</div>
32692
32743
  </div>
32693
32744
  <div class="client-metric-tile">
@@ -32719,7 +32770,7 @@ function renderClientSummary(report) {
32719
32770
  return `<div class="client-bar-row">
32720
32771
  <span class="bar-label">${escapeHtml2(reportProviderDisplayName(r.provider))}</span>
32721
32772
  <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
32722
- <span class="bar-value">${r.mentionRate}% <span class="bar-value-sub">(${r.mentionedCount}/${r.totalCount})</span></span>
32773
+ <span class="bar-value">${formatPercent(r.mentionRate, RatioUnits.percent)} <span class="bar-value-sub">(${r.mentionedCount}/${r.totalCount})</span></span>
32723
32774
  </div>`;
32724
32775
  }).join("")}
32725
32776
  </div>
@@ -32759,7 +32810,7 @@ function renderClientEvidenceSummary(report) {
32759
32810
  cards.push(`<div class="client-card">
32760
32811
  <h3>${copy.indexing.heading}</h3>
32761
32812
  <p class="card-subtitle">${copy.indexing.subtitle}</p>
32762
- <div class="client-progress-number tone-${tone}">${indexing.indexedPct}%</div>
32813
+ <div class="client-progress-number tone-${tone}">${formatPercent(indexing.indexedPct, RatioUnits.percent)}</div>
32763
32814
  <div style="font-size:12px;color:${COLORS.textMuted};">${reportClientIndexedPages(indexing.indexed, indexing.total)}</div>
32764
32815
  <div class="client-progress-bar"><div class="client-progress-fill tone-${tone}" style="width:${fillPct}%"></div></div>
32765
32816
  <p style="margin:0;font-size:12px;color:${COLORS.textMuted};"><strong style="color:${COLORS.text};">${formatNumber(indexing.notIndexed)}</strong> ${reportClientNotIndexedTail(indexing.notIndexed)}</p>
@@ -33180,7 +33231,7 @@ function buildGscSection(db, projectId, projectBrandNames, canonicalDomain, trac
33180
33231
  category,
33181
33232
  clicks: agg.clicks,
33182
33233
  impressions: agg.impressions,
33183
- sharePct: categoryTotalClicks > 0 ? Math.round(agg.clicks / categoryTotalClicks * 100) : 0
33234
+ sharePct: percentOf(agg.clicks, categoryTotalClicks) ?? 0
33184
33235
  })).sort((a, b) => b.clicks - a.clicks);
33185
33236
  const periodStart = trend[0]?.date ?? "";
33186
33237
  const periodEnd = trend.at(-1)?.date ?? "";
@@ -33284,7 +33335,7 @@ function buildGaSection(db, projectId, windowDays2) {
33284
33335
  channelBreakdown.push({
33285
33336
  channel: b.channel,
33286
33337
  sessions: b.sessions,
33287
- sharePct: Math.round(b.sessions / totalSessions * 100)
33338
+ sharePct: percentOf(b.sessions, totalSessions) ?? 0
33288
33339
  });
33289
33340
  }
33290
33341
  }
@@ -33327,7 +33378,7 @@ function buildSocialReferrals(db, projectId, windowDays2) {
33327
33378
  const channels = [...channelAgg.entries()].map(([channelGroup, sessions]) => ({
33328
33379
  channelGroup,
33329
33380
  sessions,
33330
- sharePct: total > 0 ? Math.round(sessions / total * 100) : 0
33381
+ sharePct: percentOf(sessions, total) ?? 0
33331
33382
  })).sort((a, b) => b.sessions - a.sessions);
33332
33383
  const topCampaigns = [...campaignAgg.values()].sort((a, b) => b.sessions - a.sessions).slice(0, TOP_CAMPAIGN_LIMIT);
33333
33384
  return {
@@ -33409,7 +33460,7 @@ function buildAiReferrals(db, projectId, windowDays2) {
33409
33460
  sessions: data.sessions,
33410
33461
  paidSessions: data.paidSessions,
33411
33462
  organicSessions: data.organicSessions,
33412
- sharePct: total > 0 ? Math.round(data.sessions / total * 100) : 0
33463
+ sharePct: percentOf(data.sessions, total) ?? 0
33413
33464
  })).sort((a, b) => b.sessions - a.sessions);
33414
33465
  const trend = [...trendAgg.entries()].map(([date, sessions]) => ({ date, sessions })).sort((a, b) => a.date.localeCompare(b.date));
33415
33466
  const topLandingPages = [...pageAgg.entries()].map(([page3, data]) => ({ page: page3, sessions: data.sessions })).sort((a, b) => b.sessions - a.sessions).slice(0, TOP_AI_REFERRAL_PAGES_LIMIT);
@@ -33740,7 +33791,7 @@ function buildIndexingHealth(db, projectId) {
33740
33791
  notIndexed: gsc.notIndexed,
33741
33792
  deindexed: 0,
33742
33793
  unknown: 0,
33743
- indexedPct: total > 0 ? Math.round(gsc.indexed / total * 100) : 0
33794
+ indexedPct: percentOf(gsc.indexed, total) ?? 0
33744
33795
  };
33745
33796
  }
33746
33797
  const bing = db.select().from(bingCoverageSnapshots).where(eq37(bingCoverageSnapshots.projectId, projectId)).orderBy(desc17(bingCoverageSnapshots.date)).limit(1).get();
@@ -33753,7 +33804,7 @@ function buildIndexingHealth(db, projectId) {
33753
33804
  notIndexed: bing.notIndexed,
33754
33805
  deindexed: 0,
33755
33806
  unknown: bing.unknown,
33756
- indexedPct: total > 0 ? Math.round(bing.indexed / total * 100) : 0
33807
+ indexedPct: percentOf(bing.indexed, total) ?? 0
33757
33808
  };
33758
33809
  }
33759
33810
  return null;
@@ -33784,12 +33835,12 @@ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
33784
33835
  const totalQueries = new Set(snaps.map((snapshot) => snapshot.queryId)).size;
33785
33836
  const citedQueryCount = citedQueryIds.size;
33786
33837
  const mentionedQueryCount = mentionedQueryIds.size;
33787
- const citationRate = totalQueries > 0 ? Math.round(citedQueryCount / totalQueries * 100) : 0;
33788
- const mentionRate3 = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
33838
+ const citationRate = percentOf(citedQueryCount, totalQueries) ?? 0;
33839
+ const mentionRate3 = percentOf(mentionedQueryCount, totalQueries) ?? 0;
33789
33840
  const providerRates = [...providerCounts.entries()].map(([provider, counts2]) => ({
33790
33841
  provider,
33791
- citationRate: counts2.total > 0 ? Math.round(counts2.cited / counts2.total * 100) : 0,
33792
- mentionRate: counts2.total > 0 ? Math.round(counts2.mentioned / counts2.total * 100) : 0
33842
+ citationRate: percentOf(counts2.cited, counts2.total) ?? 0,
33843
+ mentionRate: percentOf(counts2.mentioned, counts2.total) ?? 0
33793
33844
  })).sort((a, b) => a.provider.localeCompare(b.provider));
33794
33845
  points.push({
33795
33846
  runId: run.id,
@@ -33911,7 +33962,7 @@ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount,
33911
33962
  const queryNoun = totalQueryCount === 1 ? "query" : "queries";
33912
33963
  const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
33913
33964
  findings.push({
33914
- title: `Citation rate at ${citationRate}%${ratioFragment}`,
33965
+ title: `Citation rate at ${formatPercent(citationRate, RatioUnits.percent)}${ratioFragment}`,
33915
33966
  detail,
33916
33967
  tone
33917
33968
  });
@@ -34044,7 +34095,7 @@ function buildReportActionPlan(input) {
34044
34095
  if (input.indexingHealth && input.indexingHealth.total > 0 && input.indexingHealth.indexedPct < 70) {
34045
34096
  const ih = input.indexingHealth;
34046
34097
  const evidence = [
34047
- `${ih.indexedPct}% indexed (${ih.indexed}/${ih.total})`,
34098
+ `${formatPercent(ih.indexedPct, RatioUnits.percent)} indexed (${ih.indexed}/${ih.total})`,
34048
34099
  `${ih.notIndexed} not indexed${ih.deindexed > 0 ? `, ${ih.deindexed} deindexed` : ""}`
34049
34100
  ];
34050
34101
  actions.push({
@@ -34119,7 +34170,7 @@ function buildReportActionPlan(input) {
34119
34170
  "They are stronger evidence than a generic topic list because the model is already retrieving competing content."
34120
34171
  ],
34121
34172
  evidence: [
34122
- `"${topGap.query}" missed at ${formatWholePercent(topGap.missRate)} with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
34173
+ `"${topGap.query}" missed at ${formatPercent(topGap.missRate)} with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
34123
34174
  `Cited competitors: ${reportCompactList(topGap.competitorDomains)}`
34124
34175
  ],
34125
34176
  successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
@@ -34186,7 +34237,7 @@ function buildClientSummary(reportLike) {
34186
34237
  const s = reportLike.executiveSummary;
34187
34238
  const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
34188
34239
  const headline = (s.totalQueryCount ?? 0) > 0 ? `${s.mentionedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} mention the brand in AI answers` : "No tracked queries have completed a check yet";
34189
- const overview = (s.totalQueryCount ?? 0) > 0 ? `${reportLike.canonicalDomain} is mentioned on ${s.mentionRate}% of tracked queries and cited on ${s.citationRate}% of tracked queries. ${mentionTrendSentence(reportLike.whatsChanged.mentionRate)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
34240
+ const overview = (s.totalQueryCount ?? 0) > 0 ? `${reportLike.canonicalDomain} is mentioned on ${formatPercent(s.mentionRate, RatioUnits.percent)} of tracked queries and cited on ${formatPercent(s.citationRate, RatioUnits.percent)} of tracked queries. ${mentionTrendSentence(reportLike.whatsChanged.mentionRate)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
34190
34241
  const confidenceNotes = [];
34191
34242
  if (s.totalQueryCount === 0) {
34192
34243
  confidenceNotes.push("Confidence is low until the first tracked query check completes.");
@@ -34240,7 +34291,7 @@ function buildAgencyDiagnostics(input) {
34240
34291
  const provider = input.indexingHealth.provider ?? "the connected provider";
34241
34292
  diagnostics.push({
34242
34293
  title: "Indexing health",
34243
- detail: inspected ? `${input.indexingHealth.indexedPct}% of inspected URLs are indexed in ${provider}.` : `No URLs have been inspected in ${provider} yet, so indexing coverage is not measured.`,
34294
+ detail: inspected ? `${formatPercent(input.indexingHealth.indexedPct, RatioUnits.percent)} of inspected URLs are indexed in ${provider}.` : `No URLs have been inspected in ${provider} yet, so indexing coverage is not measured.`,
34244
34295
  // Not measured is not a finding, so it must not carry a finding's tone.
34245
34296
  severity: !inspected ? "neutral" : input.indexingHealth.indexedPct >= 90 ? "positive" : input.indexingHealth.indexedPct >= 70 ? "caution" : "negative",
34246
34297
  evidence: inspected ? [
@@ -34292,7 +34343,7 @@ function buildWhatsChangedHeadline(citation, gscClicks, aiReferrals, enoughHisto
34292
34343
  const arrow = citation.direction === "up" ? "\u2191" : citation.direction === "down" ? "\u2193" : "\u2192";
34293
34344
  const verb = citation.direction === "up" ? "rose" : citation.direction === "down" ? "fell" : "held";
34294
34345
  const smoothingHint = citation.window && citation.window >= 2 ? ` (avg of last ${citation.window} checks)` : "";
34295
- parts.push(`Citation rate ${verb} ${citation.prior}% ${arrow} ${citation.current}%${smoothingHint}`);
34346
+ parts.push(`Citation rate ${verb} ${formatPercent(citation.prior, RatioUnits.percent)} ${arrow} ${formatPercent(citation.current, RatioUnits.percent)}${smoothingHint}`);
34296
34347
  }
34297
34348
  if (aiReferrals && aiReferrals.direction !== "flat") {
34298
34349
  const arrow = aiReferrals.direction === "up" ? "\u2191" : "\u2193";
@@ -34309,12 +34360,12 @@ function buildWhatsChanged(input) {
34309
34360
  const latest = citationsTrend.at(-1);
34310
34361
  const prior = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
34311
34362
  const enoughHistory = !baseline && latest !== void 0 && prior !== void 0;
34312
- const citationRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.citationRate);
34363
+ const citationRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.citationRate, SMOOTHED_RUN_DELTA_MAX_WINDOW, RatioUnits.percent);
34313
34364
  const citationRate = enoughHistory && citationRateSmoothed ? {
34314
34365
  ...citationRateSmoothed,
34315
34366
  direction: rateDirection(citationRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
34316
34367
  } : null;
34317
- const mentionRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionRate);
34368
+ const mentionRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionRate, SMOOTHED_RUN_DELTA_MAX_WINDOW, RatioUnits.percent);
34318
34369
  const mentionRate3 = enoughHistory && mentionRateSmoothed ? {
34319
34370
  ...mentionRateSmoothed,
34320
34371
  direction: rateDirection(mentionRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
@@ -34335,7 +34386,7 @@ function buildWhatsChanged(input) {
34335
34386
  for (const cur of latest.providerRates) {
34336
34387
  const priorRate = priorByProvider.get(cur.provider);
34337
34388
  if (priorRate === void 0) continue;
34338
- const deltaAbs = cur.citationRate - priorRate;
34389
+ const deltaAbs = roundRatio(cur.citationRate - priorRate, RatioUnits.percent);
34339
34390
  providerMovements.push({
34340
34391
  provider: cur.provider,
34341
34392
  current: cur.citationRate,
@@ -34448,7 +34499,7 @@ function buildProjectReport(db, projectName, periodDays) {
34448
34499
  pressureLabel: row.mentions === 0 ? "None" : row.mentions / share.snapshotsWithAnswerText >= 0.5 ? "High" : row.mentions / share.snapshotsWithAnswerText >= 0.2 ? "Moderate" : "Low",
34449
34500
  mentionCount: counts2.get(row.domain) ?? 0,
34450
34501
  totalCount: share.snapshotsWithAnswerText,
34451
- sharePct: share.availability === "measured" ? Math.round((counts2.get(row.domain) ?? 0) / (share.projectMentions + share.competitorMentions) * 1e3) / 10 : null
34502
+ sharePct: share.availability === "measured" ? percentOf(counts2.get(row.domain) ?? 0, share.projectMentions + share.competitorMentions) : null
34452
34503
  }));
34453
34504
  }
34454
34505
  Object.assign(mentionLandscape, mentionLandscape.nonBrand);
@@ -34498,8 +34549,8 @@ function buildProjectReport(db, projectName, periodDays) {
34498
34549
  }
34499
34550
  const citedQueryCount = citedQueryIds.size;
34500
34551
  const mentionedQueryCount = mentionedQueryIds.size;
34501
- const citationRate = totalQueryCount > 0 ? Math.round(citedQueryCount / totalQueryCount * 100) : 0;
34502
- const mentionRate3 = totalQueryCount > 0 ? Math.round(mentionedQueryCount / totalQueryCount * 100) : 0;
34552
+ const citationRate = percentOf(citedQueryCount, totalQueryCount) ?? 0;
34553
+ const mentionRate3 = percentOf(mentionedQueryCount, totalQueryCount) ?? 0;
34503
34554
  const trendBaseline = isTrendBaseline(citationsTrend);
34504
34555
  const latestPoint = citationsTrend.at(-1);
34505
34556
  const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
@@ -35333,7 +35384,7 @@ function summarizeServer(crawlers, fetches, referrals) {
35333
35384
  function comparisonDetail(latest, prior) {
35334
35385
  if (prior === 0) return `${latest} in the latest cohort versus 0 in the prior cohort`;
35335
35386
  const change = deltaPercent(latest, prior) ?? 0;
35336
- return `${latest} in the latest cohort versus ${prior} prior (${change >= 0 ? "+" : ""}${change}%)`;
35387
+ return `${latest} in the latest cohort versus ${prior} prior (${formatSignedPercent(change, RatioUnits.percent)})`;
35337
35388
  }
35338
35389
  function aggregateCoverage(row) {
35339
35390
  return row?.startDate && row.endDate ? { startDate: row.startDate, endDate: row.endDate, observedDays: Number(row.observedDays) } : null;
@@ -36050,7 +36101,7 @@ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable,
36050
36101
  snaps.map((s) => ({
36051
36102
  // Share uses current identity; named-rate counts above deliberately keep
36052
36103
  // their historical persisted-boolean semantics.
36053
- projectMentioned: s.answerText ? matcherMatchesText(projectMatcher, s.answerText) : s.answerMentioned === true,
36104
+ projectMentioned: s.answerText ? matcherMatchesText(projectMatcher, answerProseForMentions(s.answerText)) : s.answerMentioned === true,
36054
36105
  answerText: s.answerText,
36055
36106
  queryClass: queryClassOf(s)
36056
36107
  })),
@@ -36250,9 +36301,6 @@ function computeVisibilityCompare(input) {
36250
36301
  }
36251
36302
 
36252
36303
  // ../api-routes/src/visibility-stats.ts
36253
- function round42(value) {
36254
- return Math.round(value * 1e4) / 1e4;
36255
- }
36256
36304
  function emptyAgg() {
36257
36305
  return { total: 0, checked: 0, mentioned: 0, cited: 0, first: null, last: null };
36258
36306
  }
@@ -36272,8 +36320,8 @@ function counts(agg) {
36272
36320
  cited: agg.cited,
36273
36321
  // mention proportion is over the CHECKED sample; citation proportion is
36274
36322
  // over the full total (every snapshot is checked for citation).
36275
- mentionRate: agg.checked > 0 ? round42(agg.mentioned / agg.checked) : null,
36276
- citedRate: agg.total > 0 ? round42(agg.cited / agg.total) : null
36323
+ mentionRate: agg.checked > 0 ? roundRatio(agg.mentioned / agg.checked, RatioUnits.fraction) : null,
36324
+ citedRate: agg.total > 0 ? roundRatio(agg.cited / agg.total, RatioUnits.fraction) : null
36277
36325
  };
36278
36326
  }
36279
36327
  function providerEntries(byProvider) {
@@ -36964,7 +37012,7 @@ async function compositeRoutes(app) {
36964
37012
  const answerDomainsByText = /* @__PURE__ */ new Map();
36965
37013
  for (const snapshot of trackedLatest) {
36966
37014
  if (snapshot.answerText && !answerDomainsByText.has(snapshot.answerText)) {
36967
- answerDomainsByText.set(snapshot.answerText, extractDomainsFromText(snapshot.answerText));
37015
+ answerDomainsByText.set(snapshot.answerText, extractDomainsFromText(answerProseForMentions(snapshot.answerText)));
36968
37016
  }
36969
37017
  }
36970
37018
  const mentionShareInputs = buildMentionShareInputs({
@@ -37367,13 +37415,13 @@ function buildIndexCoverageScore(app, projectId) {
37367
37415
  const deindexedLabel = deindexed === 1 ? "URL" : "URLs";
37368
37416
  return {
37369
37417
  label: "Index Coverage",
37370
- value: `${Math.round(percentage)}`,
37418
+ value: formatPercent(chosen.indexed / total),
37371
37419
  delta: `${chosen.provider} \xB7 ${chosen.indexed} of ${total} indexed`,
37372
37420
  tone,
37373
37421
  description: deindexed > 0 ? `${deindexed} deindexed ${deindexedLabel} detected in the latest Google Search Console inspection.` : `${chosen.notIndexed} ${notIndexedLabel} not indexed in ${chosen.provider === "Google" ? "Google Search Console" : "Bing Webmaster Tools"}.`,
37374
37422
  tooltip,
37375
37423
  trend: [],
37376
- progress: Math.round(percentage)
37424
+ progress: roundRatio(percentage, RatioUnits.percent)
37377
37425
  };
37378
37426
  }
37379
37427
  function countGoogleDeindexedUrls(app, projectId) {
@@ -37726,6 +37774,9 @@ var SCHEMA_TABLE = {
37726
37774
  GA4PropertiesDto: ga4PropertiesDtoSchema,
37727
37775
  GA4StatusDto: ga4StatusDtoSchema,
37728
37776
  GA4SyncResponseDto: ga4SyncResponseDtoSchema,
37777
+ GaAttributionTrendResponse: gaAttributionTrendResponseSchema,
37778
+ GaSocialReferralTrendResponse: gaSocialReferralTrendResponseSchema,
37779
+ GaTrafficResponse: gaTrafficResponseSchema,
37729
37780
  GbpAccountListResponse: gbpAccountListResponseSchema,
37730
37781
  GbpDailyMetricListResponse: gbpDailyMetricListResponseSchema,
37731
37782
  GbpKeywordImpressionListResponse: gbpKeywordImpressionListResponseSchema,
@@ -37983,6 +38034,7 @@ function errorResponse(description) {
37983
38034
  }
37984
38035
 
37985
38036
  // ../api-routes/src/openapi.ts
38037
+ var GA_MOVER_DESCRIPTION = `A biggest mover is the source whose sessions changed most, in either direction, over the last 7 days against the 7 before. A source with no sessions in the prior 7 days has changeBasis new and a null changePct: a change from zero has no percentage. Below ${MIN_PCT_BASE} prior sessions changeBasis is small-base and changeSessions is the figure to state.`;
37986
38038
  var stringSchema = { type: "string" };
37987
38039
  var booleanSchema = { type: "boolean" };
37988
38040
  var integerSchema = { type: "integer" };
@@ -43556,11 +43608,11 @@ var routeCatalog = [
43556
43608
  method: "get",
43557
43609
  path: "/api/v1/projects/{name}/ga/traffic",
43558
43610
  summary: "Get GA4 landing page traffic, channel breakdown, and AI referral landing pages",
43611
+ description: "Every figure is measured over one window (windowStart to windowEnd). Each AI and social referral row carries share, its fraction of that table's sessions, so a table's shares add up to 1; each top page carries organicShare, its organic sessions over its own sessions.",
43559
43612
  tags: ["ga4"],
43560
43613
  parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
43561
43614
  responses: {
43562
- // TODO: Add `GaTrafficResponse` Zod schema in contracts.
43563
- 200: rawJsonResponse("GA4 traffic data returned.", looseObjectSchema),
43615
+ 200: jsonResponse("GA4 traffic data returned.", "GaTrafficResponse"),
43564
43616
  400: errorResponse("GA4 is not connected."),
43565
43617
  404: errorResponse("Project not found.")
43566
43618
  }
@@ -43607,11 +43659,11 @@ var routeCatalog = [
43607
43659
  method: "get",
43608
43660
  path: "/api/v1/projects/{name}/ga/social-referral-trend",
43609
43661
  summary: "Get social referral trend (7d/30d) with biggest mover",
43662
+ description: GA_MOVER_DESCRIPTION,
43610
43663
  tags: ["ga4"],
43611
43664
  parameters: [nameParameter],
43612
43665
  responses: {
43613
- // TODO: Add `GaSocialReferralTrendResponse` Zod schema in contracts.
43614
- 200: rawJsonResponse("Social referral trend returned.", looseObjectSchema),
43666
+ 200: jsonResponse("Social referral trend returned.", "GaSocialReferralTrendResponse"),
43615
43667
  400: errorResponse("GA4 is not connected."),
43616
43668
  404: errorResponse("Project not found.")
43617
43669
  }
@@ -43620,11 +43672,11 @@ var routeCatalog = [
43620
43672
  method: "get",
43621
43673
  path: "/api/v1/projects/{name}/ga/attribution-trend",
43622
43674
  summary: "Get per-channel attribution trends (7d/30d) for organic, AI, and social",
43675
+ description: GA_MOVER_DESCRIPTION,
43623
43676
  tags: ["ga4"],
43624
43677
  parameters: [nameParameter],
43625
43678
  responses: {
43626
- // TODO: Add `GaAttributionTrendResponse` Zod schema in contracts.
43627
- 200: rawJsonResponse("Attribution trend returned.", looseObjectSchema),
43679
+ 200: jsonResponse("Attribution trend returned.", "GaAttributionTrendResponse"),
43628
43680
  400: errorResponse("GA4 is not connected."),
43629
43681
  404: errorResponse("Project not found.")
43630
43682
  }
@@ -46426,10 +46478,6 @@ function daysBetween(from, to) {
46426
46478
  const [ty, tm, td] = to.split("-").map(Number);
46427
46479
  return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 864e5);
46428
46480
  }
46429
- function roundPct(numerator, denominator) {
46430
- if (denominator === 0) return 0;
46431
- return Math.round(numerator / denominator * 100);
46432
- }
46433
46481
  function computeWindowDelta(rows, referenceDate) {
46434
46482
  const recentLow = shiftDate(referenceDate, -7);
46435
46483
  const priorLow = shiftDate(referenceDate, -14);
@@ -46494,7 +46542,7 @@ function buildTimeseries(rows, freshness, windowDays2 = 30) {
46494
46542
  function computeKeywordCoverage(rows) {
46495
46543
  const total = rows.length;
46496
46544
  const thresholdedCount = rows.filter((r) => r.valueCount === null).length;
46497
- return { total, thresholdedCount, thresholdedPct: roundPct(thresholdedCount, total) };
46545
+ return { total, thresholdedCount, thresholdedPct: percentOf(thresholdedCount, total) ?? 0 };
46498
46546
  }
46499
46547
  function summarizePlaceActions(rows) {
46500
46548
  return {
@@ -49269,7 +49317,12 @@ async function googleRoutes(app, opts) {
49269
49317
  indexed,
49270
49318
  notIndexed,
49271
49319
  deindexed: deindexedUrls.length,
49272
- percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
49320
+ percentage: percentOf(indexed, total) ?? 0,
49321
+ // The coverage donut's two arcs and the CLI summary. Every latest
49322
+ // inspection lands in exactly one bucket, so the two shares sum to 1.
49323
+ // Unrounded, so the two arcs add up to 1.
49324
+ indexedShare: total > 0 ? indexed / total : null,
49325
+ notIndexedShare: total > 0 ? notIndexed / total : null
49273
49326
  },
49274
49327
  lastInspectedAt,
49275
49328
  lastSyncedAt,
@@ -49887,7 +49940,7 @@ async function googleRoutes(app, opts) {
49887
49940
  valueThreshold: r.valueThreshold ?? null
49888
49941
  })),
49889
49942
  total: rows.length,
49890
- thresholdedPct: rows.length ? Math.round(thresholded / rows.length * 100) : 0
49943
+ thresholdedPct: percentOf(thresholded, rows.length) ?? 0
49891
49944
  };
49892
49945
  });
49893
49946
  app.get("/projects/:name/gbp/place-actions", async (request) => {
@@ -57212,7 +57265,7 @@ async function bingRoutes(app, opts) {
57212
57265
  indexed,
57213
57266
  notIndexed,
57214
57267
  unknown,
57215
- percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
57268
+ percentage: percentOf(indexed, total) ?? 0
57216
57269
  },
57217
57270
  lastInspectedAt,
57218
57271
  indexed: indexedUrls.map(formatRow),
@@ -57658,6 +57711,47 @@ function buildSessionHistory(landingPageDays, dailyTotals) {
57658
57711
  });
57659
57712
  }
57660
57713
 
57714
+ // ../api-routes/src/ga-source-mover.ts
57715
+ function moverChangeBasis(prior) {
57716
+ if (prior <= 0) return GaMoverChangeBases.new;
57717
+ return prior < MIN_PCT_BASE ? GaMoverChangeBases["small-base"] : GaMoverChangeBases.percent;
57718
+ }
57719
+ function buildSourceMover(source, sessions7d, sessionsPrev7d) {
57720
+ return {
57721
+ source,
57722
+ sessions7d,
57723
+ sessionsPrev7d,
57724
+ changeSessions: sessions7d - sessionsPrev7d,
57725
+ // Null from a zero prior. It used to fall back to 100, so every source that
57726
+ // appeared this week read as "+100%" whatever it sent.
57727
+ changePct: deltaPercent(sessions7d, sessionsPrev7d),
57728
+ changeBasis: moverChangeBasis(sessionsPrev7d)
57729
+ };
57730
+ }
57731
+ function findBiggestMover(current, prior) {
57732
+ const bySource = /* @__PURE__ */ new Map();
57733
+ const entry = (source) => {
57734
+ let sessions = bySource.get(source);
57735
+ if (!sessions) {
57736
+ sessions = { current: 0, prior: 0 };
57737
+ bySource.set(source, sessions);
57738
+ }
57739
+ return sessions;
57740
+ };
57741
+ for (const row of current) entry(row.source).current += row.sessions ?? 0;
57742
+ for (const row of prior) entry(row.source).prior += row.sessions ?? 0;
57743
+ let mover = null;
57744
+ const sources = [...bySource.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
57745
+ for (const source of sources) {
57746
+ const sessions = bySource.get(source);
57747
+ const size = Math.abs(sessions.current - sessions.prior);
57748
+ if (size > 0 && (mover === null || size > Math.abs(mover.changeSessions))) {
57749
+ mover = buildSourceMover(source, sessions.current, sessions.prior);
57750
+ }
57751
+ }
57752
+ return mover;
57753
+ }
57754
+
57661
57755
  // ../api-routes/src/ga.ts
57662
57756
  function gaLog(level, action, ctx) {
57663
57757
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
@@ -57665,12 +57759,11 @@ function gaLog(level, action, ctx) {
57665
57759
  stream.write(JSON.stringify(entry) + "\n");
57666
57760
  }
57667
57761
  function formatSharePct(numerator, total) {
57668
- if (numerator > 0 && total <= 0) return "\u2014";
57669
- if (total <= 0 || numerator <= 0) return "0%";
57670
- const pct = numerator / total * 100;
57671
- const rounded = Math.round(pct);
57672
- if (rounded === 0) return "<1%";
57673
- return `${rounded}%`;
57762
+ return formatPercent(shareOf(numerator, total));
57763
+ }
57764
+ function withSessionShares(rows) {
57765
+ const shares = breakdownShares(rows.map((row) => row.sessions));
57766
+ return rows.map((row, index2) => ({ ...row, share: shares[index2] ?? 0 }));
57674
57767
  }
57675
57768
  function dateRangeConditions(column, range) {
57676
57769
  const conditions = [];
@@ -57694,7 +57787,7 @@ function buildChannelBreakdown(input) {
57694
57787
  const otherSessions = Math.max(0, input.totalSessions - coveredSessions);
57695
57788
  const bucket = (sessions) => ({
57696
57789
  sessions,
57697
- sharePct: input.totalSessions > 0 ? Math.round(sessions / input.totalSessions * 100) : 0,
57790
+ sharePct: percentOf(sessions, input.totalSessions) ?? 0,
57698
57791
  sharePctDisplay: formatSharePct(sessions, input.totalSessions)
57699
57792
  });
57700
57793
  return {
@@ -57704,7 +57797,7 @@ function buildChannelBreakdown(input) {
57704
57797
  ai: bucket(aiSessions),
57705
57798
  other: {
57706
57799
  sessions: otherSessions,
57707
- sharePct: input.totalSessions > 0 ? Math.round(otherSessions / input.totalSessions * 100) : 0,
57800
+ sharePct: percentOf(otherSessions, input.totalSessions) ?? 0,
57708
57801
  sharePctDisplay: input.totalSessions <= 0 && coveredSessions > 0 ? "\u2014" : formatSharePct(otherSessions, input.totalSessions)
57709
57802
  }
57710
57803
  };
@@ -58536,7 +58629,8 @@ async function ga4Routes(app, opts) {
58536
58629
  sessions: r.sessions ?? 0,
58537
58630
  organicSessions: r.organicSessions ?? 0,
58538
58631
  directSessions: r.directSessions ?? 0,
58539
- users: r.users ?? 0
58632
+ users: r.users ?? 0,
58633
+ organicShare: shareOf(r.organicSessions ?? 0, r.sessions ?? 0)
58540
58634
  })),
58541
58635
  // No `users` on either referral row type, and no `ai*Users*` totals: GA
58542
58636
  // reports users as a COUNT DISTINCT at the grain requested, so summing
@@ -58544,13 +58638,16 @@ async function ga4Routes(app, opts) {
58544
58638
  // every date in the window) counted one visitor once per row. Withdrawn
58545
58639
  // in 4.135.0; see `ga-ai-referral-aggregation.ts` for why no correct
58546
58640
  // figure can be computed or fetched.
58547
- aiReferrals: aiReferrals.map((r) => ({
58641
+ // Shares of the rows' own sum, not of `aiSessionsDeduped`: that total
58642
+ // keeps the winning lens per day, these rows one lens per source over the
58643
+ // window, so it can exceed what the rows add up to.
58644
+ aiReferrals: withSessionShares(aiReferrals.map((r) => ({
58548
58645
  source: r.source,
58549
58646
  medium: r.medium,
58550
58647
  trafficClass: normalizeAiTrafficClass(r.trafficClass),
58551
58648
  sourceDimension: r.sourceDimension,
58552
58649
  sessions: r.sessions ?? 0
58553
- })),
58650
+ }))),
58554
58651
  aiReferralLandingPages: aiReferralLandingPages.map((r) => ({
58555
58652
  source: r.source,
58556
58653
  medium: r.medium,
@@ -58565,23 +58662,25 @@ async function ga4Routes(app, opts) {
58565
58662
  aiSessionsBySession: aiSummary.bySession.sessions,
58566
58663
  paidAiSessionsBySession: aiSummary.paidBySession.sessions,
58567
58664
  organicAiSessionsBySession: aiSummary.organicBySession.sessions,
58568
- socialReferrals: socialReferrals.map((r) => ({
58665
+ // These rows partition `socialSessions` (same window, one lens), so each
58666
+ // share is also the row's share of that total.
58667
+ socialReferrals: withSessionShares(socialReferrals.map((r) => ({
58569
58668
  source: r.source,
58570
58669
  medium: r.medium,
58571
58670
  channelGroup: r.channelGroup,
58572
58671
  sessions: r.sessions ?? 0
58573
- })),
58672
+ }))),
58574
58673
  socialSessions,
58575
58674
  channelBreakdown,
58576
- organicSharePct: total > 0 ? Math.round(totalOrganicSessions / total * 100) : 0,
58577
- aiSharePct: total > 0 ? Math.round(aiSummary.deduped.sessions / total * 100) : 0,
58578
- aiSharePctBySession: total > 0 ? Math.round(aiSummary.bySession.sessions / total * 100) : 0,
58579
- paidAiSharePct: total > 0 ? Math.round(aiSummary.paidDeduped.sessions / total * 100) : 0,
58580
- paidAiSharePctBySession: total > 0 ? Math.round(aiSummary.paidBySession.sessions / total * 100) : 0,
58581
- organicAiSharePct: total > 0 ? Math.round(aiSummary.organicDeduped.sessions / total * 100) : 0,
58582
- organicAiSharePctBySession: total > 0 ? Math.round(aiSummary.organicBySession.sessions / total * 100) : 0,
58583
- directSharePct: total > 0 ? Math.round(totalDirectSessions / total * 100) : 0,
58584
- socialSharePct: total > 0 ? Math.round(socialSessions / total * 100) : 0,
58675
+ organicSharePct: percentOf(totalOrganicSessions, total) ?? 0,
58676
+ aiSharePct: percentOf(aiSummary.deduped.sessions, total) ?? 0,
58677
+ aiSharePctBySession: percentOf(aiSummary.bySession.sessions, total) ?? 0,
58678
+ paidAiSharePct: percentOf(aiSummary.paidDeduped.sessions, total) ?? 0,
58679
+ paidAiSharePctBySession: percentOf(aiSummary.paidBySession.sessions, total) ?? 0,
58680
+ organicAiSharePct: percentOf(aiSummary.organicDeduped.sessions, total) ?? 0,
58681
+ organicAiSharePctBySession: percentOf(aiSummary.organicBySession.sessions, total) ?? 0,
58682
+ directSharePct: percentOf(totalDirectSessions, total) ?? 0,
58683
+ socialSharePct: percentOf(socialSessions, total) ?? 0,
58585
58684
  otherSessions: channelBreakdown.other.sessions,
58586
58685
  otherSharePct: channelBreakdown.other.sharePct,
58587
58686
  otherSharePctDisplay: channelBreakdown.other.sharePctDisplay,
@@ -58703,31 +58802,16 @@ async function ga4Routes(app, opts) {
58703
58802
  sql21`${gaSocialReferrals.date} >= ${daysAgo(14)}`,
58704
58803
  sql21`${gaSocialReferrals.date} < ${daysAgo(7)}`
58705
58804
  )).groupBy(gaSocialReferrals.source).all();
58706
- const prevMap = new Map(sourcePrev.map((r) => [r.source, r.sessions]));
58707
- let biggestMover = null;
58708
- let maxDelta = 0;
58709
- for (const row of sourceCurrent) {
58710
- const prev = prevMap.get(row.source) ?? 0;
58711
- const delta = Math.abs(row.sessions - prev);
58712
- if (delta > maxDelta) {
58713
- maxDelta = delta;
58714
- biggestMover = {
58715
- source: row.source,
58716
- sessions7d: row.sessions,
58717
- sessionsPrev7d: prev,
58718
- changePct: deltaPercent(row.sessions, prev) ?? (row.sessions > 0 ? 100 : 0)
58719
- };
58720
- }
58721
- }
58722
- return {
58805
+ const response = {
58723
58806
  socialSessions7d: current7d?.sessions ?? 0,
58724
58807
  socialSessionsPrev7d: prev7d?.sessions ?? 0,
58725
58808
  trend7dPct: deltaPercent(current7d?.sessions ?? 0, prev7d?.sessions ?? 0),
58726
58809
  socialSessions30d: current30d?.sessions ?? 0,
58727
58810
  socialSessionsPrev30d: prev30d?.sessions ?? 0,
58728
58811
  trend30dPct: deltaPercent(current30d?.sessions ?? 0, prev30d?.sessions ?? 0),
58729
- biggestMover
58812
+ biggestMover: findBiggestMover(sourceCurrent, sourcePrev)
58730
58813
  };
58814
+ return response;
58731
58815
  });
58732
58816
  app.get("/projects/:name/ga/attribution-trend", async (request, _reply) => {
58733
58817
  const project = resolveProject(app.db, request.params.name);
@@ -58769,23 +58853,9 @@ async function ga4Routes(app, opts) {
58769
58853
  sql21`${gaAiReferrals.date} < ${daysAgo(7)}`,
58770
58854
  eq55(gaAiReferrals.sourceDimension, "session")
58771
58855
  )).groupBy(gaAiReferrals.source).all();
58772
- const findBiggestMover = (current, prev) => {
58773
- const prevMap = new Map(prev.map((r) => [r.source, r.sessions]));
58774
- let mover = null;
58775
- let maxDelta = 0;
58776
- for (const row of current) {
58777
- const p = prevMap.get(row.source) ?? 0;
58778
- const delta = Math.abs(row.sessions - p);
58779
- if (delta > maxDelta) {
58780
- maxDelta = delta;
58781
- mover = { source: row.source, sessions7d: row.sessions, sessionsPrev7d: p, changePct: deltaPercent(row.sessions, p) ?? (row.sessions > 0 ? 100 : 0) };
58782
- }
58783
- }
58784
- return mover;
58785
- };
58786
58856
  const socialSourceCurrent = app.db.select({ source: gaSocialReferrals.source, sessions: sql21`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and47(eq55(gaSocialReferrals.projectId, project.id), sql21`${gaSocialReferrals.date} >= ${daysAgo(7)}`, sql21`${gaSocialReferrals.date} < ${todayStr}`)).groupBy(gaSocialReferrals.source).all();
58787
58857
  const socialSourcePrev = app.db.select({ source: gaSocialReferrals.source, sessions: sql21`SUM(${gaSocialReferrals.sessions})` }).from(gaSocialReferrals).where(and47(eq55(gaSocialReferrals.projectId, project.id), sql21`${gaSocialReferrals.date} >= ${daysAgo(14)}`, sql21`${gaSocialReferrals.date} < ${daysAgo(7)}`)).groupBy(gaSocialReferrals.source).all();
58788
- return {
58858
+ const response = {
58789
58859
  total: buildTrend(sumTotal),
58790
58860
  organic: buildTrend(sumOrganic),
58791
58861
  ai: buildTrend(sumAi),
@@ -58794,6 +58864,7 @@ async function ga4Routes(app, opts) {
58794
58864
  aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
58795
58865
  socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
58796
58866
  };
58867
+ return response;
58797
58868
  });
58798
58869
  app.get("/projects/:name/ga/session-history", async (request, _reply) => {
58799
58870
  const project = resolveProject(app.db, request.params.name);
@@ -82342,9 +82413,6 @@ function loadProject(ctx) {
82342
82413
  if (!ctx.project) return null;
82343
82414
  return ctx.db.select().from(projects).where(eq66(projects.id, ctx.project.id)).get() ?? null;
82344
82415
  }
82345
- function percent(value) {
82346
- return Math.round(value * 100);
82347
- }
82348
82416
  var winnabilityCoverageCheck = {
82349
82417
  id: "content.winnability.coverage",
82350
82418
  category: CheckCategories.integrations,
@@ -82412,7 +82480,7 @@ var winnabilityCoverageCheck = {
82412
82480
  return {
82413
82481
  status: CheckStatuses.warn,
82414
82482
  code: "content.winnability.low-coverage",
82415
- summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${percent(coverage)}%); the winnability gate may miss ceded surfaces in the unrecognized tail.`,
82483
+ summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${formatPercent(coverage)}); the winnability gate may miss ceded surfaces in the unrecognized tail.`,
82416
82484
  remediation: discoverRemediation,
82417
82485
  details
82418
82486
  };
@@ -82420,7 +82488,7 @@ var winnabilityCoverageCheck = {
82420
82488
  return {
82421
82489
  status: CheckStatuses.ok,
82422
82490
  code: "content.winnability.covered",
82423
- summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${percent(coverage)}%); the winnability gate is active.`,
82491
+ summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${formatPercent(coverage)}); the winnability gate is active.`,
82424
82492
  remediation: null,
82425
82493
  details
82426
82494
  };
@@ -86113,6 +86181,9 @@ function mapCrawlPage(row) {
86113
86181
  healthState: deriveSiteHealthState(row)
86114
86182
  };
86115
86183
  }
86184
+ function withRecordedShare(factor) {
86185
+ return { ...factor, sharePct: typeof factor.sharePct === "number" ? factor.sharePct : null };
86186
+ }
86116
86187
  function mapCrawlPageAuditEvidence(row) {
86117
86188
  const fields = row.auditFields;
86118
86189
  const rawFactors = Array.isArray(fields.factors) ? fields.factors : [];
@@ -86399,7 +86470,7 @@ async function technicalAeoRoutes(app, opts) {
86399
86470
  trend,
86400
86471
  previousScore: previous?.aggregateScore ?? null,
86401
86472
  previousAuditedAt: previous?.auditedAt ?? null,
86402
- factors: snap.factorAverages,
86473
+ factors: snap.factorAverages.map(withRecordedShare),
86403
86474
  crossCuttingIssues: snap.crossCuttingIssues,
86404
86475
  prioritizedFixes: snap.prioritizedFixes
86405
86476
  };
@@ -86436,7 +86507,7 @@ async function technicalAeoRoutes(app, opts) {
86436
86507
  overallScore: row.overallScore,
86437
86508
  status: row.status === "error" ? "error" : "success",
86438
86509
  error: row.error,
86439
- factors: row.factors
86510
+ factors: row.factors.map(withRecordedShare)
86440
86511
  }));
86441
86512
  return { project: project.name, runId: latest.runId, auditedAt: latest.auditedAt, total, pages };
86442
86513
  });
@@ -89288,10 +89359,11 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
89288
89359
  "whyitstandsout",
89289
89360
  "whywechoseit"
89290
89361
  ]);
89362
+ const scanText = stripCitationChips(answerText);
89291
89363
  const seen2 = /* @__PURE__ */ new Map();
89292
89364
  for (const pattern of candidatePatterns) {
89293
89365
  let match;
89294
- while ((match = pattern.exec(answerText)) !== null) {
89366
+ while ((match = pattern.exec(scanText)) !== null) {
89295
89367
  const candidate = cleanCandidateName(match[1]);
89296
89368
  const candidateKey2 = brandKeyFromText(candidate);
89297
89369
  if (!candidateKey2) continue;
@@ -90164,6 +90236,7 @@ export {
90164
90236
  deliverWebhook,
90165
90237
  redactNotificationUrl,
90166
90238
  renderReportHtml,
90239
+ buildOpenApiDocument,
90167
90240
  toAlertView,
90168
90241
  resolveDestination,
90169
90242
  getPlaceDetails,