@canonry/canonry 5.28.1 → 5.29.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 (33) hide show
  1. package/assets/assets/{AuditHistoryPanel-CWd9UN6L.js → AuditHistoryPanel-ru-RI1SR.js} +1 -1
  2. package/assets/assets/{BacklinksPage-B2gPTzyE.js → BacklinksPage-BbVWtfAh.js} +1 -1
  3. package/assets/assets/{HistoryPage-xtkG8SZN.js → HistoryPage-CxIxsxIv.js} +1 -1
  4. package/assets/assets/MeasurementPropertyPage-Oju3oAP3.js +1 -0
  5. package/assets/assets/ProjectPage-ZsQo28yM.js +8 -0
  6. package/assets/assets/{RunRow-DnSo7Nle.js → RunRow-D73cbxey.js} +1 -1
  7. package/assets/assets/{RunsPage-FAEuo4Ra.js → RunsPage-D9hOl6k6.js} +1 -1
  8. package/assets/assets/{SettingsPage-C4OZ1HB9.js → SettingsPage-By5cJJaC.js} +1 -1
  9. package/assets/assets/SiteHealthSection-Gfyispq_.js +4 -0
  10. package/assets/assets/{TrafficPage-D03S6xF6.js → TrafficPage-8khxNXDL.js} +2 -2
  11. package/assets/assets/{TrafficSourceDetailPage-DBu6oFFK.js → TrafficSourceDetailPage-FDe4jKLH.js} +1 -1
  12. package/assets/assets/{extract-error-message-BuH2CCaJ.js → extract-error-message-Co1hxrsc.js} +1 -1
  13. package/assets/assets/index-DKScDg92.js +86 -0
  14. package/assets/assets/{react-sigma_core.esm.min-CxwZsgxR.js → react-sigma_core.esm.min-DxAxqyeI.js} +1 -1
  15. package/assets/assets/{v2-overview-adapter-DTCKxzZT.js → v2-overview-adapter-DNbOwIJq.js} +1 -1
  16. package/assets/index.html +1 -1
  17. package/dist/{chunk-EP4STEPI.js → chunk-7BW7BAD5.js} +164 -96
  18. package/dist/{chunk-KVQHG3WB.js → chunk-DCGELKRW.js} +6234 -5911
  19. package/dist/{chunk-MPICT6V7.js → chunk-J6BJIOIN.js} +3 -3
  20. package/dist/{chunk-7RKCDOWO.js → chunk-KGYHEYHX.js} +238 -174
  21. package/dist/{chunk-ONTEFAA7.js → chunk-THMIDWMS.js} +57 -3
  22. package/dist/{chunk-FNGITGDF.js → chunk-VPIOF2PB.js} +2 -2
  23. package/dist/{chunk-DX2JUC5H.js → chunk-WPQB4UYX.js} +3 -3
  24. package/dist/cli.js +134 -123
  25. package/dist/{demo-server-HWWIMGD6.js → demo-server-EU6B4KNK.js} +24 -32
  26. package/dist/index.js +6 -6
  27. package/dist/{intelligence-service-6LDMVI66.js → intelligence-service-FVUNBN66.js} +2 -2
  28. package/dist/mcp.js +4 -4
  29. package/package.json +9 -9
  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,
@@ -238,6 +241,7 @@ import {
238
241
  brandKeyFromText,
239
242
  brandLabelFromDomain,
240
243
  brandMetricsDtoSchema,
244
+ breakdownShares,
241
245
  buildHarvestAnchorTerms,
242
246
  buildMeasurementExecutionIdentity,
243
247
  buildMeasurementRunManifestV1,
@@ -351,8 +355,8 @@ import {
351
355
  formatIsoDate,
352
356
  formatIsoDateInTimeZone,
353
357
  formatNumber,
354
- formatRatio,
355
- formatWholePercent,
358
+ formatPercent,
359
+ formatSignedPercent,
356
360
  formatWindowCountDelta,
357
361
  ga4AiReferralDailyDtoSchema,
358
362
  ga4AiReferralHistoryEntrySchema,
@@ -361,9 +365,12 @@ import {
361
365
  ga4SocialReferralHistoryEntrySchema,
362
366
  ga4StatusDtoSchema,
363
367
  ga4SyncResponseDtoSchema,
368
+ gaAttributionTrendResponseSchema,
364
369
  gaMeasurementAnalysisDtoSchema,
365
370
  gaMeasurementAnalysisWindowSchema,
366
371
  gaMeasurementHostScopeSchema,
372
+ gaSocialReferralTrendResponseSchema,
373
+ gaTrafficResponseSchema,
367
374
  gateHarvestedSearchQueries,
368
375
  gbpAccountListResponseSchema,
369
376
  gbpAttributesListResponseSchema,
@@ -584,6 +591,7 @@ import {
584
591
  parseRunError,
585
592
  parseStoredMeasurementPlanAnyVersion,
586
593
  parseWindow,
594
+ percentOf,
587
595
  pickClusterRepresentative,
588
596
  prepareBrandMatchText,
589
597
  projectConfigSchema,
@@ -665,6 +673,7 @@ import {
665
673
  reportProviderRateLabel,
666
674
  reportQueryClassLabel,
667
675
  reportRateDeltaCopy,
676
+ reportRateDeltaValue,
668
677
  reportReferralRedirectNote,
669
678
  reportServerActivityAgencyOperatorHeaders,
670
679
  reportServerActivityAgencyTiles,
@@ -708,6 +717,7 @@ import {
708
717
  resultsClearResponseSchema,
709
718
  resultsExportDtoSchema,
710
719
  retryAfterDelayMs,
720
+ roundRatio,
711
721
  runCompletenessDtoSchema,
712
722
  runDetailDtoSchema,
713
723
  runDtoSchema,
@@ -734,6 +744,7 @@ import {
734
744
  segmentCrawlerHits,
735
745
  serializeRunError,
736
746
  settingsDtoSchema,
747
+ shareOf,
737
748
  shareOfVoiceReason,
738
749
  shareOfVoiceSummary,
739
750
  shiftIsoCalendarDate,
@@ -825,7 +836,7 @@ import {
825
836
  wordpressSchemaDeployResultDtoSchema,
826
837
  wordpressSchemaStatusResultDtoSchema,
827
838
  wordpressStatusDtoSchema
828
- } from "./chunk-KVQHG3WB.js";
839
+ } from "./chunk-DCGELKRW.js";
829
840
 
830
841
  // src/intelligence-service.ts
831
842
  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 +9070,7 @@ function buildDrivers(input) {
9059
9070
  drivers.push(`${formatImpressions(input.gscImpressions)} GSC impressions`);
9060
9071
  }
9061
9072
  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`);
9073
+ drivers.push(`missed in ${formatPercent(clamp01(input.recentMissRate))} of recent runs`);
9064
9074
  }
9065
9075
  if (input.action === "create" && input.position === null) {
9066
9076
  drivers.push("no existing page");
@@ -9443,8 +9453,8 @@ function buildCitationScorecard(snapshots, queryLookup) {
9443
9453
  }
9444
9454
  const providerRates = providerList.map((provider) => {
9445
9455
  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;
9456
+ const citationRate = percentOf(counts2.cited, counts2.total) ?? 0;
9457
+ const mentionRate3 = percentOf(counts2.mentioned, counts2.total) ?? 0;
9448
9458
  return {
9449
9459
  provider,
9450
9460
  citedCount: counts2.cited,
@@ -9549,7 +9559,7 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
9549
9559
  else if (ratio >= 0.2) pressureLabel = "Moderate";
9550
9560
  else pressureLabel = "Low";
9551
9561
  }
9552
- const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
9562
+ const sharePct = percentOf(data.count, totalCitedSlots) ?? 0;
9553
9563
  const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
9554
9564
  return {
9555
9565
  domain,
@@ -9584,17 +9594,37 @@ function toBreakdown(tally, competitors2) {
9584
9594
  const perCompetitor = competitors2.map((c) => ({
9585
9595
  domain: c.domain,
9586
9596
  mentionSnapshots: tally.competitorCounts.get(c.domain) ?? 0,
9587
- shareOfCompetitiveTotal: competitorMentionSnapshots > 0 ? Math.round((tally.competitorCounts.get(c.domain) ?? 0) / competitorMentionSnapshots * 1e3) / 10 : 0
9597
+ shareOfCompetitiveTotal: percentOf(tally.competitorCounts.get(c.domain) ?? 0, competitorMentionSnapshots) ?? 0
9588
9598
  })).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1));
9589
9599
  return {
9590
9600
  projectMentionSnapshots: tally.projectMentionSnapshots,
9591
9601
  competitorMentionSnapshots,
9602
+ combinedMentionSnapshots: denom,
9592
9603
  perCompetitor,
9604
+ ranking: rankMentionShare(tally, competitors2, denom),
9593
9605
  snapshotsWithAnswerText: tally.snapshotsWithAnswerText,
9594
9606
  snapshotsTotal: tally.snapshotsTotal,
9595
- score: denom > 0 ? Math.round(tally.projectMentionSnapshots / denom * 100) : null
9607
+ score: percentOf(tally.projectMentionSnapshots, denom)
9596
9608
  };
9597
9609
  }
9610
+ function rankMentionShare(tally, competitors2, denom) {
9611
+ if (competitors2.length === 0 || denom === 0) return [];
9612
+ const project = {
9613
+ kind: "project",
9614
+ domain: null,
9615
+ mentionSnapshots: tally.projectMentionSnapshots,
9616
+ share: tally.projectMentionSnapshots / denom
9617
+ };
9618
+ 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) => ({
9619
+ kind: "competitor",
9620
+ domain: c.domain,
9621
+ mentionSnapshots: c.mentionSnapshots,
9622
+ share: c.mentionSnapshots / denom
9623
+ }));
9624
+ const firstNotAbove = rivals.findIndex((row) => row.mentionSnapshots <= project.mentionSnapshots);
9625
+ const at = firstNotAbove === -1 ? rivals.length : firstNotAbove;
9626
+ return [...rivals.slice(0, at), project, ...rivals.slice(at)];
9627
+ }
9598
9628
  function buildMentionShare(snapshots, options) {
9599
9629
  const competitorMatchers = new Map(
9600
9630
  options.competitors.map((c) => [c.domain, compileBrandAliases(usableBrandAliases([...c.brandTokens]))])
@@ -9691,7 +9721,7 @@ function buildMentionShare(snapshots, options) {
9691
9721
  value: "No mentions",
9692
9722
  delta: scopeLabel(scope, "No brand mentions in this run"),
9693
9723
  tone: "neutral",
9694
- description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
9724
+ description: describe({ scope, breakdown: headline, branded: brandedBreakdown }),
9695
9725
  tooltip,
9696
9726
  trend: [],
9697
9727
  scope,
@@ -9701,10 +9731,10 @@ function buildMentionShare(snapshots, options) {
9701
9731
  }
9702
9732
  return {
9703
9733
  label: "Mention Share",
9704
- value: `${score}`,
9734
+ value: formatPercent(headline.projectMentionSnapshots / denom),
9705
9735
  delta: scopeLabel(scope, `${headline.projectMentionSnapshots} of ${denom} brand mentions`),
9706
9736
  tone: mentionShareTone(score),
9707
- description: describe({ scope, score, breakdown: headline, branded: brandedBreakdown }),
9737
+ description: describe({ scope, breakdown: headline, branded: brandedBreakdown }),
9708
9738
  tooltip,
9709
9739
  trend: [],
9710
9740
  progress: score,
@@ -9722,7 +9752,7 @@ function mentionShareTone(score) {
9722
9752
  return "negative";
9723
9753
  }
9724
9754
  function describe(parts) {
9725
- const { scope, score, breakdown: breakdown2, branded } = parts;
9755
+ const { scope, breakdown: breakdown2, branded } = parts;
9726
9756
  const { projectMentionSnapshots, competitorMentionSnapshots, perCompetitor } = breakdown2;
9727
9757
  const where = scope === "non-brand" ? "on non-brand queries" : "across your tracked queries";
9728
9758
  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 +9764,11 @@ function describe(parts) {
9734
9764
  }
9735
9765
  const top = perCompetitor[0];
9736
9766
  const total = projectMentionSnapshots + competitorMentionSnapshots;
9767
+ const share = formatPercent(projectMentionSnapshots / total);
9737
9768
  if (!top) {
9738
- return `${score}% of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
9769
+ return `${share} of brand mentions ${where} are you (${projectMentionSnapshots} of ${total}).${brandedNote}`;
9739
9770
  }
9740
- return `${score}% of brand mentions ${where} are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).${brandedNote}`;
9771
+ return `${share} of brand mentions ${where} are you. Top competitor: ${top.domain} (${top.mentionSnapshots} mentions).${brandedNote}`;
9741
9772
  }
9742
9773
 
9743
9774
  // ../intelligence/src/share-of-voice-frame.ts
@@ -9754,7 +9785,7 @@ function buildShareOfVoiceFrame(input) {
9754
9785
  basis,
9755
9786
  reason,
9756
9787
  availability: reason === null ? "measured" : "not-measured",
9757
- score: reason === null ? Math.round(input.projectMentions / denominator * 1e3) / 10 : null,
9788
+ score: reason === null ? percentOf(input.projectMentions, denominator) : null,
9758
9789
  denominator,
9759
9790
  competitorMentions,
9760
9791
  domains
@@ -10058,7 +10089,7 @@ function finalizeRow(row, answeredResults, mentionCredits, competitive) {
10058
10089
  surfaceClass: row.surfaceClass,
10059
10090
  pinned: row.pinned,
10060
10091
  mentionCount: row.mentionCount,
10061
- shareOfVoice: competitive ? mentionCredits > 0 ? roundPercentage(row.mentionCount / mentionCredits * 100) : null : null,
10092
+ shareOfVoice: competitive ? percentOf(row.mentionCount, mentionCredits) : null,
10062
10093
  citationCount: row.citationCount,
10063
10094
  answeredResults,
10064
10095
  firstSeenAt: row.firstSeenAt,
@@ -10066,9 +10097,6 @@ function finalizeRow(row, answeredResults, mentionCredits, competitive) {
10066
10097
  sampleUrls: [...row.sampleUrls]
10067
10098
  };
10068
10099
  }
10069
- function roundPercentage(value) {
10070
- return Math.round(value * 10) / 10;
10071
- }
10072
10100
  function compareCompetitiveRows(a, b) {
10073
10101
  return b.mentionCount - a.mentionCount || b.citationCount - a.citationCount || a.domain.localeCompare(b.domain);
10074
10102
  }
@@ -10097,7 +10125,7 @@ function toSection(tally) {
10097
10125
  else if (ratio >= 0.2) pressureLabel = "Moderate";
10098
10126
  else pressureLabel = "Low";
10099
10127
  }
10100
- const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : null;
10128
+ const sharePct = percentOf(data.count, totalMentionedSlots);
10101
10129
  return {
10102
10130
  domain,
10103
10131
  mentionCount: data.count,
@@ -10186,7 +10214,7 @@ function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains, topDo
10186
10214
  category,
10187
10215
  label,
10188
10216
  count: count4,
10189
- sharePct: totalCitations > 0 ? Math.round(count4 / totalCitations * 100) : 0
10217
+ sharePct: percentOf(count4, totalCitations) ?? 0
10190
10218
  })).sort((a, b) => b.count - a.count);
10191
10219
  const topDomains = [...domainCounts.entries()].map(([domain, count4]) => ({
10192
10220
  domain,
@@ -10378,13 +10406,13 @@ function buildVisibilityScore(snapshots, options) {
10378
10406
  }
10379
10407
  const totalCount = queryCited.size;
10380
10408
  const citedCount = [...queryCited.values()].filter(Boolean).length;
10381
- const score = totalCount > 0 ? Math.round(citedCount / totalCount * 100) : 0;
10409
+ const score = percentOf(citedCount, totalCount) ?? 0;
10382
10410
  const runProviders = new Set(snapshots.map((s) => s.provider));
10383
10411
  const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
10384
10412
  const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
10385
10413
  return {
10386
10414
  label: "Citation Coverage",
10387
- value: `${score}`,
10415
+ value: formatPercent(citedCount / totalCount),
10388
10416
  delta: `${citedCount} of ${totalCount} queries cited`,
10389
10417
  tone: isPartialProviderRun ? "caution" : scoreTone(score),
10390
10418
  description: `${citedCount} of ${totalCount} tracked queries found your domain in at least one AI answer engine.`,
@@ -10416,13 +10444,13 @@ function buildMentionCoverage(snapshots, options) {
10416
10444
  }
10417
10445
  const totalCount = queryMentioned.size;
10418
10446
  const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
10419
- const score = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
10447
+ const score = percentOf(mentionedCount, totalCount) ?? 0;
10420
10448
  const runProviders = new Set(snapshots.map((s) => s.provider));
10421
10449
  const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
10422
10450
  const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
10423
10451
  return {
10424
10452
  label: "Mention Coverage",
10425
- value: `${score}`,
10453
+ value: formatPercent(mentionedCount / totalCount),
10426
10454
  delta: `${mentionedCount} of ${totalCount} queries mentioned`,
10427
10455
  tone: isPartialProviderRun ? "caution" : scoreTone(score),
10428
10456
  description: `${mentionedCount} of ${totalCount} tracked queries had your brand or domain in the AI answer text.`,
@@ -10468,7 +10496,7 @@ function buildGapQueryScore(snapshots) {
10468
10496
  description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive citation gaps detected in the latest visibility run.",
10469
10497
  tooltip,
10470
10498
  trend: [],
10471
- progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
10499
+ progress: percentOf(gapCount, totalCount) ?? 0
10472
10500
  };
10473
10501
  }
10474
10502
  function buildMentionGapScore(snapshots) {
@@ -10505,7 +10533,7 @@ function buildMentionGapScore(snapshots) {
10505
10533
  description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} mention competitors but never your brand.` : "No competitive mention gaps detected in the latest visibility run.",
10506
10534
  tooltip,
10507
10535
  trend: [],
10508
- progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
10536
+ progress: percentOf(gapCount, totalCount) ?? 0
10509
10537
  };
10510
10538
  }
10511
10539
 
@@ -10586,7 +10614,7 @@ function buildProviderScores(snapshots) {
10586
10614
  ).map(({ provider, model, cited, total }) => ({
10587
10615
  provider,
10588
10616
  model,
10589
- score: total > 0 ? Math.round(cited / total * 100) : 0,
10617
+ score: percentOf(cited, total) ?? 0,
10590
10618
  cited,
10591
10619
  total
10592
10620
  }));
@@ -10608,9 +10636,9 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
10608
10636
  }
10609
10637
  const totalCount = queryCited.size;
10610
10638
  const citedCount = [...queryCited.values()].filter(Boolean).length;
10611
- const citationRate = totalCount > 0 ? Math.round(citedCount / totalCount * 100) : 0;
10639
+ const citationRate = percentOf(citedCount, totalCount) ?? 0;
10612
10640
  const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
10613
- const mentionRate3 = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
10641
+ const mentionRate3 = percentOf(mentionedCount, totalCount) ?? 0;
10614
10642
  return {
10615
10643
  runId: run.id,
10616
10644
  createdAt: run.createdAt,
@@ -10642,7 +10670,8 @@ function buildProviderTrends(runs2, snapshotsByRunId, limit = 12) {
10642
10670
  }
10643
10671
  for (const key of keys) {
10644
10672
  const queryMap = perKey.get(key);
10645
- const rate2 = queryMap && queryMap.size > 0 ? Math.round([...queryMap.values()].filter(Boolean).length / queryMap.size * 100) : 0;
10673
+ const cited = queryMap ? [...queryMap.values()].filter(Boolean).length : 0;
10674
+ const rate2 = percentOf(cited, queryMap?.size ?? 0) ?? 0;
10646
10675
  result.get(key).push({ rate: rate2, createdAt: run.createdAt });
10647
10676
  }
10648
10677
  }
@@ -10713,7 +10742,7 @@ function formatImpressions2(value) {
10713
10742
 
10714
10743
  // ../intelligence/src/smoothed-delta.ts
10715
10744
  var SMOOTHED_RUN_DELTA_MAX_WINDOW = 3;
10716
- function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WINDOW) {
10745
+ function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WINDOW, ratioUnit) {
10717
10746
  if (points.length < 2) return null;
10718
10747
  const window = Math.min(maxWindow, Math.floor(points.length / 2));
10719
10748
  const tail = points.slice(-window);
@@ -10721,8 +10750,9 @@ function smoothedRunDelta(points, valueFn, maxWindow = SMOOTHED_RUN_DELTA_MAX_WI
10721
10750
  const sum = (arr) => arr.reduce((s, p) => s + valueFn(p), 0);
10722
10751
  const currentAvg = sum(tail) / tail.length;
10723
10752
  const priorAvg = sum(prior) / prior.length;
10724
- const current = roundTo1Decimal(currentAvg);
10725
- const prior_ = roundTo1Decimal(priorAvg);
10753
+ const round = (value) => ratioUnit ? roundRatio(value, ratioUnit) : roundTo1Decimal(value);
10754
+ const current = round(currentAvg);
10755
+ const prior_ = round(priorAvg);
10726
10756
  return {
10727
10757
  current,
10728
10758
  prior: prior_,
@@ -10819,7 +10849,7 @@ function analyzeGbp(signals) {
10819
10849
  ...base,
10820
10850
  type: "gbp-metric-drop",
10821
10851
  severity: abs >= GBP_METRIC_SEVERE_PCT ? "high" : "medium",
10822
- title: `${loc.displayName}: ${metricLabel(worstMetric.metric)} down ${abs}% week-over-week`,
10852
+ title: `${loc.displayName}: ${metricLabel(worstMetric.metric)} down ${formatPercent(abs, RatioUnits.percent)} week-over-week`,
10823
10853
  recommendation: {
10824
10854
  action: "Investigate the local-visibility drop (profile changes, category edits, new competition)",
10825
10855
  reason: `${metricLabel(worstMetric.metric)} fell from ${worstMetric.prior} to ${worstMetric.recent} (last 7d vs the prior 7d).`
@@ -10833,7 +10863,7 @@ function analyzeGbp(signals) {
10833
10863
  ...base,
10834
10864
  type: "gbp-keyword-drop",
10835
10865
  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}`,
10866
+ title: `${loc.displayName}: "${worstKeyword.keyword}" impressions down ${formatPercent(worstKeyword.dropPct, RatioUnits.percent)} month-over-month${window}`,
10837
10867
  recommendation: {
10838
10868
  action: "Check whether the property still ranks for this local search term and refresh the profile",
10839
10869
  reason: `Search-keyword impressions for "${worstKeyword.keyword}" fell from ${worstKeyword.prior} to ${worstKeyword.recent} month-over-month.`
@@ -10867,7 +10897,7 @@ function pickWorstKeywordDrop(loc) {
10867
10897
  if (point.recent == null || point.prior == null || point.prior < GBP_KEYWORD_MIN_BASELINE) continue;
10868
10898
  const changeRatio = relativeChangeRatio(point.recent, point.prior);
10869
10899
  if (changeRatio === null) continue;
10870
- const dropPct = Math.round(-changeRatio * 100);
10900
+ const dropPct = roundRatio(-changeRatio * 100, RatioUnits.percent);
10871
10901
  if (dropPct < GBP_KEYWORD_DROP_PCT) continue;
10872
10902
  if (!worst || dropPct > worst.dropPct) {
10873
10903
  worst = { keyword: point.keyword, dropPct, recent: point.recent, prior: point.prior };
@@ -28627,6 +28657,7 @@ async function analyticsRoutes(app) {
28627
28657
  byProvider: {},
28628
28658
  trend: "stable",
28629
28659
  mentionTrend: "stable",
28660
+ windowChange: { citationRate: null, mentionRate: null, mentionShare: null },
28630
28661
  queryChanges: [],
28631
28662
  basketChanges: [],
28632
28663
  executionIdentityChanges: [],
@@ -28839,6 +28870,7 @@ async function analyticsRoutes(app) {
28839
28870
  }
28840
28871
  const trend = computeTrend(buckets, "citationRate");
28841
28872
  const mentionTrend = computeTrend(buckets, "mentionRate");
28873
+ const windowChange = computeWindowChange(buckets);
28842
28874
  const queryChanges = computeQueryChanges(projectQueries, cutoff);
28843
28875
  const basketChanges = basketRevisions.slice(1).map((rev, i) => {
28844
28876
  const previous = new Set(basketRevisions[i].members);
@@ -28860,7 +28892,7 @@ async function analyticsRoutes(app) {
28860
28892
  }
28861
28893
  previousExecutionChecksum = identity.checksum;
28862
28894
  }
28863
- return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
28895
+ return reply.send({ window, mentionShareScope, buckets, overall, byProvider, trend, mentionTrend, windowChange, queryChanges, basketChanges, executionIdentityChanges, referenceBasketRevision: latestBasket?.revision ?? null, modelAttribution, servedModelAttribution, modelServiceMismatch, modelPointerChanges });
28864
28896
  });
28865
28897
  app.get("/projects/:name/analytics/gaps", async (request, reply) => {
28866
28898
  const project = resolveProject(app.db, request.params.name);
@@ -29328,7 +29360,7 @@ function computeMentionShareBucketMetric(snapshots, mentionShareCompetitors, cla
29328
29360
  scope: result.scope,
29329
29361
  // A project-only denominator is recognition evidence, not competitive
29330
29362
  // share. Preserve the count but leave the rate undefined without a frame.
29331
- rate: mentionShareCompetitors.length > 0 && denominator > 0 ? round4(projectMentionSnapshots / denominator) : null,
29363
+ rate: mentionShareCompetitors.length > 0 && denominator > 0 ? roundRatio(projectMentionSnapshots / denominator, RatioUnits.fraction) : null,
29332
29364
  projectMentionSnapshots,
29333
29365
  competitorMentionSnapshots
29334
29366
  };
@@ -29358,6 +29390,20 @@ function pooledRate(buckets, rateKey) {
29358
29390
  }
29359
29391
  return denominator > 0 ? numerator / denominator : 0;
29360
29392
  }
29393
+ function windowRateChange(rates) {
29394
+ const first = rates[0];
29395
+ const latest = rates.at(-1);
29396
+ if (rates.length < 2 || first === void 0 || latest === void 0) return null;
29397
+ return { first, latest, delta: roundRatio(latest - first, RatioUnits.fraction) };
29398
+ }
29399
+ function computeWindowChange(buckets) {
29400
+ const measured = buckets.filter((b) => b.total > 0);
29401
+ return {
29402
+ citationRate: windowRateChange(measured.map((b) => b.citationRate)),
29403
+ mentionRate: windowRateChange(measured.map((b) => b.mentionRate)),
29404
+ mentionShare: windowRateChange(buckets.flatMap((b) => b.mentionShare.rate === null ? [] : [b.mentionShare.rate]))
29405
+ };
29406
+ }
29361
29407
  function computeTrend(buckets, rateKey) {
29362
29408
  const nonEmpty = buckets.filter((b) => b.total > 0);
29363
29409
  if (nonEmpty.length < 2) return "stable";
@@ -29371,9 +29417,6 @@ function computeTrend(buckets, rateKey) {
29371
29417
  if (diff < -0.05) return "declining";
29372
29418
  return "stable";
29373
29419
  }
29374
- function round4(ratio) {
29375
- return Math.round(ratio * 1e4) / 1e4;
29376
- }
29377
29420
  function bumpDomain(map, domain, category, label, surfaceClass) {
29378
29421
  const existing = map.get(domain);
29379
29422
  if (existing) existing.count += 1;
@@ -29388,8 +29431,8 @@ function buildRankedList(domains, limit, answerTotal, answersWithSources) {
29388
29431
  const entries = shownEntries.map((d) => ({
29389
29432
  domain: d.domain,
29390
29433
  count: d.count,
29391
- percentage: totalCitedSlots > 0 ? round4(d.count / totalCitedSlots) : 0,
29392
- answerShare: answerTotal > 0 ? round4(d.count / answerTotal) : 0,
29434
+ percentage: totalCitedSlots > 0 ? roundRatio(d.count / totalCitedSlots, RatioUnits.fraction) : 0,
29435
+ answerShare: answerTotal > 0 ? roundRatio(d.count / answerTotal, RatioUnits.fraction) : 0,
29393
29436
  category: d.category,
29394
29437
  label: d.label,
29395
29438
  surfaceClass: d.surfaceClass
@@ -29406,7 +29449,7 @@ function buildRankedList(domains, limit, answerTotal, answersWithSources) {
29406
29449
  surfaceClass,
29407
29450
  label: surfaceClassLabel(surfaceClass),
29408
29451
  count: v.count,
29409
- percentage: totalCitedSlots > 0 ? round4(v.count / totalCitedSlots) : 0,
29452
+ percentage: totalCitedSlots > 0 ? roundRatio(v.count / totalCitedSlots, RatioUnits.fraction) : 0,
29410
29453
  domainCount: v.domainCount
29411
29454
  })).sort((a, b) => b.count - a.count || a.surfaceClass.localeCompare(b.surfaceClass));
29412
29455
  return {
@@ -31610,7 +31653,7 @@ function renderExecutiveSummary(report) {
31610
31653
  </div>
31611
31654
  <div class="hero-proof">
31612
31655
  <div class="mini-label">${copy.proofTiles.mentionCoverage}</div>
31613
- <div class="mini-value">${s.mentionRate}%</div>
31656
+ <div class="mini-value">${formatPercent(s.mentionRate, RatioUnits.percent)}</div>
31614
31657
  <div class="mini-copy">${escapeHtml2(headline.mentionedFragment)}</div>
31615
31658
  </div>
31616
31659
  <div class="hero-proof">
@@ -31623,12 +31666,12 @@ function renderExecutiveSummary(report) {
31623
31666
  const metrics = [
31624
31667
  {
31625
31668
  label: copy.tiles.citationRate,
31626
- value: `${s.citationRate}%`,
31669
+ value: formatPercent(s.citationRate, RatioUnits.percent),
31627
31670
  delta: `<span class="tone-${headline.trendTone}">${headline.trendLabel}</span> \xB7 ${headline.citedFragment} \xB7 ${headline.providerCountLabel}`
31628
31671
  },
31629
31672
  {
31630
31673
  label: copy.tiles.mentionRate,
31631
- value: `${s.mentionRate}%`,
31674
+ value: formatPercent(s.mentionRate, RatioUnits.percent),
31632
31675
  delta: headline.mentionedFragment
31633
31676
  },
31634
31677
  {
@@ -31684,11 +31727,10 @@ function renderRateDeltaTile(label, delta, unit) {
31684
31727
  if (!delta) {
31685
31728
  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
31729
  }
31687
- const valueSuffix = unit === "%" ? "%" : "";
31688
31730
  const deltaText = reportRateDeltaCopy(delta, unit);
31689
31731
  return `<div class="metric">
31690
31732
  <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>
31733
+ <div class="value ${deltaToneClass(delta.direction)}">${reportRateDeltaValue(delta, unit)} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
31692
31734
  <div class="delta">${deltaText}</div>
31693
31735
  </div>`;
31694
31736
  }
@@ -31711,8 +31753,8 @@ function renderProviderMovements(movements, audience) {
31711
31753
  const rows = meaningful.map((m) => {
31712
31754
  return `<tr>
31713
31755
  <td>${escapeHtml2(isClient ? reportProviderDisplayName(m.provider) : m.provider)}</td>
31714
- <td class="numeric">${m.prior}%</td>
31715
- <td class="numeric">${m.current}%</td>
31756
+ <td class="numeric">${formatPercent(m.prior, RatioUnits.percent)}</td>
31757
+ <td class="numeric">${formatPercent(m.current, RatioUnits.percent)}</td>
31716
31758
  <td class="numeric ${deltaToneClass(m.direction)}">${reportMovementChangeCopy(m)}</td>
31717
31759
  </tr>`;
31718
31760
  }).join("");
@@ -31905,7 +31947,7 @@ function renderCompetitorLandscape(report) {
31905
31947
  <td><span class="badge tone-${tone}">${escapeHtml2(c.pressureLabel)}</span></td>
31906
31948
  <td class="numeric">${c.citationCount} / ${c.totalCount}</td>
31907
31949
  <td class="numeric">${mentionCount} / ${mentionTotal}</td>
31908
- <td class="numeric">${c.sharePct}%</td>
31950
+ <td class="numeric">${formatPercent(c.sharePct, RatioUnits.percent)}</td>
31909
31951
  <td>${escapeHtml2(reportTruncatedList(c.citedQueries, 5))}${pagesDisclosure}</td>
31910
31952
  </tr>`;
31911
31953
  }).join("");
@@ -32016,7 +32058,7 @@ function renderAiSourceOrigin(report) {
32016
32058
  `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
32017
32059
  );
32018
32060
  }
32019
- function renderLineChart(points, color, title, height = 200) {
32061
+ function renderLineChart(points, color, title, height = 200, formatValue = formatNumber) {
32020
32062
  if (points.length === 0) return "";
32021
32063
  const width = 600;
32022
32064
  const padX = 32;
@@ -32040,8 +32082,8 @@ function renderLineChart(points, color, title, height = 200) {
32040
32082
  <h3>${escapeHtml2(title)}</h3>
32041
32083
  <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml2(reportLineChartLabel(title))}">
32042
32084
  <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>
32085
+ <text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatValue(max2)}</text>
32086
+ <text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatValue(0)}</text>
32045
32087
  <path d="${path8}" stroke="${color}" stroke-width="2" fill="none" />
32046
32088
  ${dots}
32047
32089
  ${xLabels}
@@ -32062,7 +32104,7 @@ function renderGsc(report) {
32062
32104
  <td>${escapeHtml2(q.query)}</td>
32063
32105
  <td class="numeric">${formatNumber(q.clicks)}</td>
32064
32106
  <td class="numeric">${formatNumber(q.impressions)}</td>
32065
- <td class="numeric">${formatRatio(q.ctr)}</td>
32107
+ <td class="numeric">${formatPercent(q.ctr)}</td>
32066
32108
  <td class="numeric">${q.avgPosition.toFixed(1)}</td>
32067
32109
  <td><span class="badge tone-neutral">${escapeHtml2(q.category)}</span></td>
32068
32110
  </tr>`).join("");
@@ -32100,7 +32142,7 @@ function renderGsc(report) {
32100
32142
  `<div class="metric-grid">
32101
32143
  <div class="metric"><div class="label">${copy.tiles.clicks}</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
32102
32144
  <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>
32145
+ <div class="metric"><div class="label">${copy.tiles.ctr}</div><div class="value">${formatPercent(gsc.ctr)}</div></div>
32104
32146
  <div class="metric"><div class="label">${copy.tiles.position}</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
32105
32147
  </div>
32106
32148
  ${trendChart}
@@ -32448,7 +32490,7 @@ function renderIndexingHealth(report) {
32448
32490
  `<div class="metric-grid">
32449
32491
  <div class="metric"><div class="label">${copy.tiles.indexed}</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
32450
32492
  <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>
32493
+ <div class="metric"><div class="label">${copy.tiles.share}</div><div class="value">${formatPercent(ih.indexedPct, RatioUnits.percent)}</div></div>
32452
32494
  </div>
32453
32495
  <div class="chart-card">
32454
32496
  <h3>${copy.coverageHeading}</h3>
@@ -32476,12 +32518,13 @@ function renderCitationsTrend(report) {
32476
32518
  trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
32477
32519
  COLORS.positive,
32478
32520
  copy.chartTitle,
32479
- 220
32521
+ 220,
32522
+ (value) => formatPercent(value, RatioUnits.percent)
32480
32523
  );
32481
32524
  const rows = trend.map((t) => `
32482
32525
  <tr>
32483
32526
  <td>${formatDate(t.date)}</td>
32484
- <td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
32527
+ <td class="numeric">${formatPercent(t.citationRate, RatioUnits.percent)} <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
32485
32528
  <td>${escapeHtml2(reportTrendProviderRates(t.providerRates))}</td>
32486
32529
  </tr>`).join("");
32487
32530
  const [checkHeader, citedQueriesHeader, engineRatesHeader] = copy.breakdownHeaders;
@@ -32668,7 +32711,7 @@ function renderClientSummary(report) {
32668
32711
  const sc = report.citationScorecard;
32669
32712
  const copy = REPORT_SECTION_COPY["client-summary"];
32670
32713
  const totalQ = s.totalQueryCount ?? 0;
32671
- const heroNumber = totalQ > 0 ? `${s.mentionRate}%` : "\u2014";
32714
+ const heroNumber = totalQ > 0 ? formatPercent(s.mentionRate, RatioUnits.percent) : "\u2014";
32672
32715
  const heroSentence = reportClientHeroSentence(totalQ, s.mentionedQueryCount);
32673
32716
  const trend = reportClientTrendCopy(report.whatsChanged.mentionRate);
32674
32717
  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 +32725,12 @@ function renderClientSummary(report) {
32682
32725
  const tiles = `<div class="client-metric-grid">
32683
32726
  <div class="client-metric-tile">
32684
32727
  <div class="label">${copy.tiles.mentioned}</div>
32685
- <div class="value">${s.mentionRate}%</div>
32728
+ <div class="value">${formatPercent(s.mentionRate, RatioUnits.percent)}</div>
32686
32729
  <div class="subtitle">${reportClientMentionedSubtitle(s.mentionedQueryCount, totalQ)}</div>
32687
32730
  </div>
32688
32731
  <div class="client-metric-tile">
32689
32732
  <div class="label">${copy.tiles.cited}</div>
32690
- <div class="value">${s.citationRate}%</div>
32733
+ <div class="value">${formatPercent(s.citationRate, RatioUnits.percent)}</div>
32691
32734
  <div class="subtitle">${reportClientCitedSubtitle(s.citedQueryCount, totalQ)}</div>
32692
32735
  </div>
32693
32736
  <div class="client-metric-tile">
@@ -32719,7 +32762,7 @@ function renderClientSummary(report) {
32719
32762
  return `<div class="client-bar-row">
32720
32763
  <span class="bar-label">${escapeHtml2(reportProviderDisplayName(r.provider))}</span>
32721
32764
  <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>
32765
+ <span class="bar-value">${formatPercent(r.mentionRate, RatioUnits.percent)} <span class="bar-value-sub">(${r.mentionedCount}/${r.totalCount})</span></span>
32723
32766
  </div>`;
32724
32767
  }).join("")}
32725
32768
  </div>
@@ -32759,7 +32802,7 @@ function renderClientEvidenceSummary(report) {
32759
32802
  cards.push(`<div class="client-card">
32760
32803
  <h3>${copy.indexing.heading}</h3>
32761
32804
  <p class="card-subtitle">${copy.indexing.subtitle}</p>
32762
- <div class="client-progress-number tone-${tone}">${indexing.indexedPct}%</div>
32805
+ <div class="client-progress-number tone-${tone}">${formatPercent(indexing.indexedPct, RatioUnits.percent)}</div>
32763
32806
  <div style="font-size:12px;color:${COLORS.textMuted};">${reportClientIndexedPages(indexing.indexed, indexing.total)}</div>
32764
32807
  <div class="client-progress-bar"><div class="client-progress-fill tone-${tone}" style="width:${fillPct}%"></div></div>
32765
32808
  <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 +33223,7 @@ function buildGscSection(db, projectId, projectBrandNames, canonicalDomain, trac
33180
33223
  category,
33181
33224
  clicks: agg.clicks,
33182
33225
  impressions: agg.impressions,
33183
- sharePct: categoryTotalClicks > 0 ? Math.round(agg.clicks / categoryTotalClicks * 100) : 0
33226
+ sharePct: percentOf(agg.clicks, categoryTotalClicks) ?? 0
33184
33227
  })).sort((a, b) => b.clicks - a.clicks);
33185
33228
  const periodStart = trend[0]?.date ?? "";
33186
33229
  const periodEnd = trend.at(-1)?.date ?? "";
@@ -33284,7 +33327,7 @@ function buildGaSection(db, projectId, windowDays2) {
33284
33327
  channelBreakdown.push({
33285
33328
  channel: b.channel,
33286
33329
  sessions: b.sessions,
33287
- sharePct: Math.round(b.sessions / totalSessions * 100)
33330
+ sharePct: percentOf(b.sessions, totalSessions) ?? 0
33288
33331
  });
33289
33332
  }
33290
33333
  }
@@ -33327,7 +33370,7 @@ function buildSocialReferrals(db, projectId, windowDays2) {
33327
33370
  const channels = [...channelAgg.entries()].map(([channelGroup, sessions]) => ({
33328
33371
  channelGroup,
33329
33372
  sessions,
33330
- sharePct: total > 0 ? Math.round(sessions / total * 100) : 0
33373
+ sharePct: percentOf(sessions, total) ?? 0
33331
33374
  })).sort((a, b) => b.sessions - a.sessions);
33332
33375
  const topCampaigns = [...campaignAgg.values()].sort((a, b) => b.sessions - a.sessions).slice(0, TOP_CAMPAIGN_LIMIT);
33333
33376
  return {
@@ -33409,7 +33452,7 @@ function buildAiReferrals(db, projectId, windowDays2) {
33409
33452
  sessions: data.sessions,
33410
33453
  paidSessions: data.paidSessions,
33411
33454
  organicSessions: data.organicSessions,
33412
- sharePct: total > 0 ? Math.round(data.sessions / total * 100) : 0
33455
+ sharePct: percentOf(data.sessions, total) ?? 0
33413
33456
  })).sort((a, b) => b.sessions - a.sessions);
33414
33457
  const trend = [...trendAgg.entries()].map(([date, sessions]) => ({ date, sessions })).sort((a, b) => a.date.localeCompare(b.date));
33415
33458
  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 +33783,7 @@ function buildIndexingHealth(db, projectId) {
33740
33783
  notIndexed: gsc.notIndexed,
33741
33784
  deindexed: 0,
33742
33785
  unknown: 0,
33743
- indexedPct: total > 0 ? Math.round(gsc.indexed / total * 100) : 0
33786
+ indexedPct: percentOf(gsc.indexed, total) ?? 0
33744
33787
  };
33745
33788
  }
33746
33789
  const bing = db.select().from(bingCoverageSnapshots).where(eq37(bingCoverageSnapshots.projectId, projectId)).orderBy(desc17(bingCoverageSnapshots.date)).limit(1).get();
@@ -33753,7 +33796,7 @@ function buildIndexingHealth(db, projectId) {
33753
33796
  notIndexed: bing.notIndexed,
33754
33797
  deindexed: 0,
33755
33798
  unknown: bing.unknown,
33756
- indexedPct: total > 0 ? Math.round(bing.indexed / total * 100) : 0
33799
+ indexedPct: percentOf(bing.indexed, total) ?? 0
33757
33800
  };
33758
33801
  }
33759
33802
  return null;
@@ -33784,12 +33827,12 @@ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
33784
33827
  const totalQueries = new Set(snaps.map((snapshot) => snapshot.queryId)).size;
33785
33828
  const citedQueryCount = citedQueryIds.size;
33786
33829
  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;
33830
+ const citationRate = percentOf(citedQueryCount, totalQueries) ?? 0;
33831
+ const mentionRate3 = percentOf(mentionedQueryCount, totalQueries) ?? 0;
33789
33832
  const providerRates = [...providerCounts.entries()].map(([provider, counts2]) => ({
33790
33833
  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
33834
+ citationRate: percentOf(counts2.cited, counts2.total) ?? 0,
33835
+ mentionRate: percentOf(counts2.mentioned, counts2.total) ?? 0
33793
33836
  })).sort((a, b) => a.provider.localeCompare(b.provider));
33794
33837
  points.push({
33795
33838
  runId: run.id,
@@ -33911,7 +33954,7 @@ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount,
33911
33954
  const queryNoun = totalQueryCount === 1 ? "query" : "queries";
33912
33955
  const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
33913
33956
  findings.push({
33914
- title: `Citation rate at ${citationRate}%${ratioFragment}`,
33957
+ title: `Citation rate at ${formatPercent(citationRate, RatioUnits.percent)}${ratioFragment}`,
33915
33958
  detail,
33916
33959
  tone
33917
33960
  });
@@ -34044,7 +34087,7 @@ function buildReportActionPlan(input) {
34044
34087
  if (input.indexingHealth && input.indexingHealth.total > 0 && input.indexingHealth.indexedPct < 70) {
34045
34088
  const ih = input.indexingHealth;
34046
34089
  const evidence = [
34047
- `${ih.indexedPct}% indexed (${ih.indexed}/${ih.total})`,
34090
+ `${formatPercent(ih.indexedPct, RatioUnits.percent)} indexed (${ih.indexed}/${ih.total})`,
34048
34091
  `${ih.notIndexed} not indexed${ih.deindexed > 0 ? `, ${ih.deindexed} deindexed` : ""}`
34049
34092
  ];
34050
34093
  actions.push({
@@ -34119,7 +34162,7 @@ function buildReportActionPlan(input) {
34119
34162
  "They are stronger evidence than a generic topic list because the model is already retrieving competing content."
34120
34163
  ],
34121
34164
  evidence: [
34122
- `"${topGap.query}" missed at ${formatWholePercent(topGap.missRate)} with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
34165
+ `"${topGap.query}" missed at ${formatPercent(topGap.missRate)} with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
34123
34166
  `Cited competitors: ${reportCompactList(topGap.competitorDomains)}`
34124
34167
  ],
34125
34168
  successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
@@ -34186,7 +34229,7 @@ function buildClientSummary(reportLike) {
34186
34229
  const s = reportLike.executiveSummary;
34187
34230
  const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
34188
34231
  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.";
34232
+ 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
34233
  const confidenceNotes = [];
34191
34234
  if (s.totalQueryCount === 0) {
34192
34235
  confidenceNotes.push("Confidence is low until the first tracked query check completes.");
@@ -34240,7 +34283,7 @@ function buildAgencyDiagnostics(input) {
34240
34283
  const provider = input.indexingHealth.provider ?? "the connected provider";
34241
34284
  diagnostics.push({
34242
34285
  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.`,
34286
+ 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
34287
  // Not measured is not a finding, so it must not carry a finding's tone.
34245
34288
  severity: !inspected ? "neutral" : input.indexingHealth.indexedPct >= 90 ? "positive" : input.indexingHealth.indexedPct >= 70 ? "caution" : "negative",
34246
34289
  evidence: inspected ? [
@@ -34292,7 +34335,7 @@ function buildWhatsChangedHeadline(citation, gscClicks, aiReferrals, enoughHisto
34292
34335
  const arrow = citation.direction === "up" ? "\u2191" : citation.direction === "down" ? "\u2193" : "\u2192";
34293
34336
  const verb = citation.direction === "up" ? "rose" : citation.direction === "down" ? "fell" : "held";
34294
34337
  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}`);
34338
+ parts.push(`Citation rate ${verb} ${formatPercent(citation.prior, RatioUnits.percent)} ${arrow} ${formatPercent(citation.current, RatioUnits.percent)}${smoothingHint}`);
34296
34339
  }
34297
34340
  if (aiReferrals && aiReferrals.direction !== "flat") {
34298
34341
  const arrow = aiReferrals.direction === "up" ? "\u2191" : "\u2193";
@@ -34309,12 +34352,12 @@ function buildWhatsChanged(input) {
34309
34352
  const latest = citationsTrend.at(-1);
34310
34353
  const prior = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
34311
34354
  const enoughHistory = !baseline && latest !== void 0 && prior !== void 0;
34312
- const citationRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.citationRate);
34355
+ const citationRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.citationRate, SMOOTHED_RUN_DELTA_MAX_WINDOW, RatioUnits.percent);
34313
34356
  const citationRate = enoughHistory && citationRateSmoothed ? {
34314
34357
  ...citationRateSmoothed,
34315
34358
  direction: rateDirection(citationRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
34316
34359
  } : null;
34317
- const mentionRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionRate);
34360
+ const mentionRateSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionRate, SMOOTHED_RUN_DELTA_MAX_WINDOW, RatioUnits.percent);
34318
34361
  const mentionRate3 = enoughHistory && mentionRateSmoothed ? {
34319
34362
  ...mentionRateSmoothed,
34320
34363
  direction: rateDirection(mentionRateSmoothed.deltaAbs, RATE_REAL_MOVEMENT_THRESHOLD_PP)
@@ -34335,7 +34378,7 @@ function buildWhatsChanged(input) {
34335
34378
  for (const cur of latest.providerRates) {
34336
34379
  const priorRate = priorByProvider.get(cur.provider);
34337
34380
  if (priorRate === void 0) continue;
34338
- const deltaAbs = cur.citationRate - priorRate;
34381
+ const deltaAbs = roundRatio(cur.citationRate - priorRate, RatioUnits.percent);
34339
34382
  providerMovements.push({
34340
34383
  provider: cur.provider,
34341
34384
  current: cur.citationRate,
@@ -34448,7 +34491,7 @@ function buildProjectReport(db, projectName, periodDays) {
34448
34491
  pressureLabel: row.mentions === 0 ? "None" : row.mentions / share.snapshotsWithAnswerText >= 0.5 ? "High" : row.mentions / share.snapshotsWithAnswerText >= 0.2 ? "Moderate" : "Low",
34449
34492
  mentionCount: counts2.get(row.domain) ?? 0,
34450
34493
  totalCount: share.snapshotsWithAnswerText,
34451
- sharePct: share.availability === "measured" ? Math.round((counts2.get(row.domain) ?? 0) / (share.projectMentions + share.competitorMentions) * 1e3) / 10 : null
34494
+ sharePct: share.availability === "measured" ? percentOf(counts2.get(row.domain) ?? 0, share.projectMentions + share.competitorMentions) : null
34452
34495
  }));
34453
34496
  }
34454
34497
  Object.assign(mentionLandscape, mentionLandscape.nonBrand);
@@ -34498,8 +34541,8 @@ function buildProjectReport(db, projectName, periodDays) {
34498
34541
  }
34499
34542
  const citedQueryCount = citedQueryIds.size;
34500
34543
  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;
34544
+ const citationRate = percentOf(citedQueryCount, totalQueryCount) ?? 0;
34545
+ const mentionRate3 = percentOf(mentionedQueryCount, totalQueryCount) ?? 0;
34503
34546
  const trendBaseline = isTrendBaseline(citationsTrend);
34504
34547
  const latestPoint = citationsTrend.at(-1);
34505
34548
  const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
@@ -35333,7 +35376,7 @@ function summarizeServer(crawlers, fetches, referrals) {
35333
35376
  function comparisonDetail(latest, prior) {
35334
35377
  if (prior === 0) return `${latest} in the latest cohort versus 0 in the prior cohort`;
35335
35378
  const change = deltaPercent(latest, prior) ?? 0;
35336
- return `${latest} in the latest cohort versus ${prior} prior (${change >= 0 ? "+" : ""}${change}%)`;
35379
+ return `${latest} in the latest cohort versus ${prior} prior (${formatSignedPercent(change, RatioUnits.percent)})`;
35337
35380
  }
35338
35381
  function aggregateCoverage(row) {
35339
35382
  return row?.startDate && row.endDate ? { startDate: row.startDate, endDate: row.endDate, observedDays: Number(row.observedDays) } : null;
@@ -36250,9 +36293,6 @@ function computeVisibilityCompare(input) {
36250
36293
  }
36251
36294
 
36252
36295
  // ../api-routes/src/visibility-stats.ts
36253
- function round42(value) {
36254
- return Math.round(value * 1e4) / 1e4;
36255
- }
36256
36296
  function emptyAgg() {
36257
36297
  return { total: 0, checked: 0, mentioned: 0, cited: 0, first: null, last: null };
36258
36298
  }
@@ -36272,8 +36312,8 @@ function counts(agg) {
36272
36312
  cited: agg.cited,
36273
36313
  // mention proportion is over the CHECKED sample; citation proportion is
36274
36314
  // 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
36315
+ mentionRate: agg.checked > 0 ? roundRatio(agg.mentioned / agg.checked, RatioUnits.fraction) : null,
36316
+ citedRate: agg.total > 0 ? roundRatio(agg.cited / agg.total, RatioUnits.fraction) : null
36277
36317
  };
36278
36318
  }
36279
36319
  function providerEntries(byProvider) {
@@ -37367,13 +37407,13 @@ function buildIndexCoverageScore(app, projectId) {
37367
37407
  const deindexedLabel = deindexed === 1 ? "URL" : "URLs";
37368
37408
  return {
37369
37409
  label: "Index Coverage",
37370
- value: `${Math.round(percentage)}`,
37410
+ value: formatPercent(chosen.indexed / total),
37371
37411
  delta: `${chosen.provider} \xB7 ${chosen.indexed} of ${total} indexed`,
37372
37412
  tone,
37373
37413
  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
37414
  tooltip,
37375
37415
  trend: [],
37376
- progress: Math.round(percentage)
37416
+ progress: roundRatio(percentage, RatioUnits.percent)
37377
37417
  };
37378
37418
  }
37379
37419
  function countGoogleDeindexedUrls(app, projectId) {
@@ -37726,6 +37766,9 @@ var SCHEMA_TABLE = {
37726
37766
  GA4PropertiesDto: ga4PropertiesDtoSchema,
37727
37767
  GA4StatusDto: ga4StatusDtoSchema,
37728
37768
  GA4SyncResponseDto: ga4SyncResponseDtoSchema,
37769
+ GaAttributionTrendResponse: gaAttributionTrendResponseSchema,
37770
+ GaSocialReferralTrendResponse: gaSocialReferralTrendResponseSchema,
37771
+ GaTrafficResponse: gaTrafficResponseSchema,
37729
37772
  GbpAccountListResponse: gbpAccountListResponseSchema,
37730
37773
  GbpDailyMetricListResponse: gbpDailyMetricListResponseSchema,
37731
37774
  GbpKeywordImpressionListResponse: gbpKeywordImpressionListResponseSchema,
@@ -37983,6 +38026,7 @@ function errorResponse(description) {
37983
38026
  }
37984
38027
 
37985
38028
  // ../api-routes/src/openapi.ts
38029
+ 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
38030
  var stringSchema = { type: "string" };
37987
38031
  var booleanSchema = { type: "boolean" };
37988
38032
  var integerSchema = { type: "integer" };
@@ -43556,11 +43600,11 @@ var routeCatalog = [
43556
43600
  method: "get",
43557
43601
  path: "/api/v1/projects/{name}/ga/traffic",
43558
43602
  summary: "Get GA4 landing page traffic, channel breakdown, and AI referral landing pages",
43603
+ 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
43604
  tags: ["ga4"],
43560
43605
  parameters: [nameParameter, limitQueryParameter, analyticsWindowParameter, analyticsStartDateParameter, analyticsEndDateParameter],
43561
43606
  responses: {
43562
- // TODO: Add `GaTrafficResponse` Zod schema in contracts.
43563
- 200: rawJsonResponse("GA4 traffic data returned.", looseObjectSchema),
43607
+ 200: jsonResponse("GA4 traffic data returned.", "GaTrafficResponse"),
43564
43608
  400: errorResponse("GA4 is not connected."),
43565
43609
  404: errorResponse("Project not found.")
43566
43610
  }
@@ -43607,11 +43651,11 @@ var routeCatalog = [
43607
43651
  method: "get",
43608
43652
  path: "/api/v1/projects/{name}/ga/social-referral-trend",
43609
43653
  summary: "Get social referral trend (7d/30d) with biggest mover",
43654
+ description: GA_MOVER_DESCRIPTION,
43610
43655
  tags: ["ga4"],
43611
43656
  parameters: [nameParameter],
43612
43657
  responses: {
43613
- // TODO: Add `GaSocialReferralTrendResponse` Zod schema in contracts.
43614
- 200: rawJsonResponse("Social referral trend returned.", looseObjectSchema),
43658
+ 200: jsonResponse("Social referral trend returned.", "GaSocialReferralTrendResponse"),
43615
43659
  400: errorResponse("GA4 is not connected."),
43616
43660
  404: errorResponse("Project not found.")
43617
43661
  }
@@ -43620,11 +43664,11 @@ var routeCatalog = [
43620
43664
  method: "get",
43621
43665
  path: "/api/v1/projects/{name}/ga/attribution-trend",
43622
43666
  summary: "Get per-channel attribution trends (7d/30d) for organic, AI, and social",
43667
+ description: GA_MOVER_DESCRIPTION,
43623
43668
  tags: ["ga4"],
43624
43669
  parameters: [nameParameter],
43625
43670
  responses: {
43626
- // TODO: Add `GaAttributionTrendResponse` Zod schema in contracts.
43627
- 200: rawJsonResponse("Attribution trend returned.", looseObjectSchema),
43671
+ 200: jsonResponse("Attribution trend returned.", "GaAttributionTrendResponse"),
43628
43672
  400: errorResponse("GA4 is not connected."),
43629
43673
  404: errorResponse("Project not found.")
43630
43674
  }
@@ -46426,10 +46470,6 @@ function daysBetween(from, to) {
46426
46470
  const [ty, tm, td] = to.split("-").map(Number);
46427
46471
  return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 864e5);
46428
46472
  }
46429
- function roundPct(numerator, denominator) {
46430
- if (denominator === 0) return 0;
46431
- return Math.round(numerator / denominator * 100);
46432
- }
46433
46473
  function computeWindowDelta(rows, referenceDate) {
46434
46474
  const recentLow = shiftDate(referenceDate, -7);
46435
46475
  const priorLow = shiftDate(referenceDate, -14);
@@ -46494,7 +46534,7 @@ function buildTimeseries(rows, freshness, windowDays2 = 30) {
46494
46534
  function computeKeywordCoverage(rows) {
46495
46535
  const total = rows.length;
46496
46536
  const thresholdedCount = rows.filter((r) => r.valueCount === null).length;
46497
- return { total, thresholdedCount, thresholdedPct: roundPct(thresholdedCount, total) };
46537
+ return { total, thresholdedCount, thresholdedPct: percentOf(thresholdedCount, total) ?? 0 };
46498
46538
  }
46499
46539
  function summarizePlaceActions(rows) {
46500
46540
  return {
@@ -49269,7 +49309,12 @@ async function googleRoutes(app, opts) {
49269
49309
  indexed,
49270
49310
  notIndexed,
49271
49311
  deindexed: deindexedUrls.length,
49272
- percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
49312
+ percentage: percentOf(indexed, total) ?? 0,
49313
+ // The coverage donut's two arcs and the CLI summary. Every latest
49314
+ // inspection lands in exactly one bucket, so the two shares sum to 1.
49315
+ // Unrounded, so the two arcs add up to 1.
49316
+ indexedShare: total > 0 ? indexed / total : null,
49317
+ notIndexedShare: total > 0 ? notIndexed / total : null
49273
49318
  },
49274
49319
  lastInspectedAt,
49275
49320
  lastSyncedAt,
@@ -49887,7 +49932,7 @@ async function googleRoutes(app, opts) {
49887
49932
  valueThreshold: r.valueThreshold ?? null
49888
49933
  })),
49889
49934
  total: rows.length,
49890
- thresholdedPct: rows.length ? Math.round(thresholded / rows.length * 100) : 0
49935
+ thresholdedPct: percentOf(thresholded, rows.length) ?? 0
49891
49936
  };
49892
49937
  });
49893
49938
  app.get("/projects/:name/gbp/place-actions", async (request) => {
@@ -57212,7 +57257,7 @@ async function bingRoutes(app, opts) {
57212
57257
  indexed,
57213
57258
  notIndexed,
57214
57259
  unknown,
57215
- percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
57260
+ percentage: percentOf(indexed, total) ?? 0
57216
57261
  },
57217
57262
  lastInspectedAt,
57218
57263
  indexed: indexedUrls.map(formatRow),
@@ -57658,6 +57703,47 @@ function buildSessionHistory(landingPageDays, dailyTotals) {
57658
57703
  });
57659
57704
  }
57660
57705
 
57706
+ // ../api-routes/src/ga-source-mover.ts
57707
+ function moverChangeBasis(prior) {
57708
+ if (prior <= 0) return GaMoverChangeBases.new;
57709
+ return prior < MIN_PCT_BASE ? GaMoverChangeBases["small-base"] : GaMoverChangeBases.percent;
57710
+ }
57711
+ function buildSourceMover(source, sessions7d, sessionsPrev7d) {
57712
+ return {
57713
+ source,
57714
+ sessions7d,
57715
+ sessionsPrev7d,
57716
+ changeSessions: sessions7d - sessionsPrev7d,
57717
+ // Null from a zero prior. It used to fall back to 100, so every source that
57718
+ // appeared this week read as "+100%" whatever it sent.
57719
+ changePct: deltaPercent(sessions7d, sessionsPrev7d),
57720
+ changeBasis: moverChangeBasis(sessionsPrev7d)
57721
+ };
57722
+ }
57723
+ function findBiggestMover(current, prior) {
57724
+ const bySource = /* @__PURE__ */ new Map();
57725
+ const entry = (source) => {
57726
+ let sessions = bySource.get(source);
57727
+ if (!sessions) {
57728
+ sessions = { current: 0, prior: 0 };
57729
+ bySource.set(source, sessions);
57730
+ }
57731
+ return sessions;
57732
+ };
57733
+ for (const row of current) entry(row.source).current += row.sessions ?? 0;
57734
+ for (const row of prior) entry(row.source).prior += row.sessions ?? 0;
57735
+ let mover = null;
57736
+ const sources = [...bySource.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
57737
+ for (const source of sources) {
57738
+ const sessions = bySource.get(source);
57739
+ const size = Math.abs(sessions.current - sessions.prior);
57740
+ if (size > 0 && (mover === null || size > Math.abs(mover.changeSessions))) {
57741
+ mover = buildSourceMover(source, sessions.current, sessions.prior);
57742
+ }
57743
+ }
57744
+ return mover;
57745
+ }
57746
+
57661
57747
  // ../api-routes/src/ga.ts
57662
57748
  function gaLog(level, action, ctx) {
57663
57749
  const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, module: "GA4Routes", action, ...ctx };
@@ -57665,12 +57751,11 @@ function gaLog(level, action, ctx) {
57665
57751
  stream.write(JSON.stringify(entry) + "\n");
57666
57752
  }
57667
57753
  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}%`;
57754
+ return formatPercent(shareOf(numerator, total));
57755
+ }
57756
+ function withSessionShares(rows) {
57757
+ const shares = breakdownShares(rows.map((row) => row.sessions));
57758
+ return rows.map((row, index2) => ({ ...row, share: shares[index2] ?? 0 }));
57674
57759
  }
57675
57760
  function dateRangeConditions(column, range) {
57676
57761
  const conditions = [];
@@ -57694,7 +57779,7 @@ function buildChannelBreakdown(input) {
57694
57779
  const otherSessions = Math.max(0, input.totalSessions - coveredSessions);
57695
57780
  const bucket = (sessions) => ({
57696
57781
  sessions,
57697
- sharePct: input.totalSessions > 0 ? Math.round(sessions / input.totalSessions * 100) : 0,
57782
+ sharePct: percentOf(sessions, input.totalSessions) ?? 0,
57698
57783
  sharePctDisplay: formatSharePct(sessions, input.totalSessions)
57699
57784
  });
57700
57785
  return {
@@ -57704,7 +57789,7 @@ function buildChannelBreakdown(input) {
57704
57789
  ai: bucket(aiSessions),
57705
57790
  other: {
57706
57791
  sessions: otherSessions,
57707
- sharePct: input.totalSessions > 0 ? Math.round(otherSessions / input.totalSessions * 100) : 0,
57792
+ sharePct: percentOf(otherSessions, input.totalSessions) ?? 0,
57708
57793
  sharePctDisplay: input.totalSessions <= 0 && coveredSessions > 0 ? "\u2014" : formatSharePct(otherSessions, input.totalSessions)
57709
57794
  }
57710
57795
  };
@@ -58536,7 +58621,8 @@ async function ga4Routes(app, opts) {
58536
58621
  sessions: r.sessions ?? 0,
58537
58622
  organicSessions: r.organicSessions ?? 0,
58538
58623
  directSessions: r.directSessions ?? 0,
58539
- users: r.users ?? 0
58624
+ users: r.users ?? 0,
58625
+ organicShare: shareOf(r.organicSessions ?? 0, r.sessions ?? 0)
58540
58626
  })),
58541
58627
  // No `users` on either referral row type, and no `ai*Users*` totals: GA
58542
58628
  // reports users as a COUNT DISTINCT at the grain requested, so summing
@@ -58544,13 +58630,16 @@ async function ga4Routes(app, opts) {
58544
58630
  // every date in the window) counted one visitor once per row. Withdrawn
58545
58631
  // in 4.135.0; see `ga-ai-referral-aggregation.ts` for why no correct
58546
58632
  // figure can be computed or fetched.
58547
- aiReferrals: aiReferrals.map((r) => ({
58633
+ // Shares of the rows' own sum, not of `aiSessionsDeduped`: that total
58634
+ // keeps the winning lens per day, these rows one lens per source over the
58635
+ // window, so it can exceed what the rows add up to.
58636
+ aiReferrals: withSessionShares(aiReferrals.map((r) => ({
58548
58637
  source: r.source,
58549
58638
  medium: r.medium,
58550
58639
  trafficClass: normalizeAiTrafficClass(r.trafficClass),
58551
58640
  sourceDimension: r.sourceDimension,
58552
58641
  sessions: r.sessions ?? 0
58553
- })),
58642
+ }))),
58554
58643
  aiReferralLandingPages: aiReferralLandingPages.map((r) => ({
58555
58644
  source: r.source,
58556
58645
  medium: r.medium,
@@ -58565,23 +58654,25 @@ async function ga4Routes(app, opts) {
58565
58654
  aiSessionsBySession: aiSummary.bySession.sessions,
58566
58655
  paidAiSessionsBySession: aiSummary.paidBySession.sessions,
58567
58656
  organicAiSessionsBySession: aiSummary.organicBySession.sessions,
58568
- socialReferrals: socialReferrals.map((r) => ({
58657
+ // These rows partition `socialSessions` (same window, one lens), so each
58658
+ // share is also the row's share of that total.
58659
+ socialReferrals: withSessionShares(socialReferrals.map((r) => ({
58569
58660
  source: r.source,
58570
58661
  medium: r.medium,
58571
58662
  channelGroup: r.channelGroup,
58572
58663
  sessions: r.sessions ?? 0
58573
- })),
58664
+ }))),
58574
58665
  socialSessions,
58575
58666
  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,
58667
+ organicSharePct: percentOf(totalOrganicSessions, total) ?? 0,
58668
+ aiSharePct: percentOf(aiSummary.deduped.sessions, total) ?? 0,
58669
+ aiSharePctBySession: percentOf(aiSummary.bySession.sessions, total) ?? 0,
58670
+ paidAiSharePct: percentOf(aiSummary.paidDeduped.sessions, total) ?? 0,
58671
+ paidAiSharePctBySession: percentOf(aiSummary.paidBySession.sessions, total) ?? 0,
58672
+ organicAiSharePct: percentOf(aiSummary.organicDeduped.sessions, total) ?? 0,
58673
+ organicAiSharePctBySession: percentOf(aiSummary.organicBySession.sessions, total) ?? 0,
58674
+ directSharePct: percentOf(totalDirectSessions, total) ?? 0,
58675
+ socialSharePct: percentOf(socialSessions, total) ?? 0,
58585
58676
  otherSessions: channelBreakdown.other.sessions,
58586
58677
  otherSharePct: channelBreakdown.other.sharePct,
58587
58678
  otherSharePctDisplay: channelBreakdown.other.sharePctDisplay,
@@ -58703,31 +58794,16 @@ async function ga4Routes(app, opts) {
58703
58794
  sql21`${gaSocialReferrals.date} >= ${daysAgo(14)}`,
58704
58795
  sql21`${gaSocialReferrals.date} < ${daysAgo(7)}`
58705
58796
  )).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 {
58797
+ const response = {
58723
58798
  socialSessions7d: current7d?.sessions ?? 0,
58724
58799
  socialSessionsPrev7d: prev7d?.sessions ?? 0,
58725
58800
  trend7dPct: deltaPercent(current7d?.sessions ?? 0, prev7d?.sessions ?? 0),
58726
58801
  socialSessions30d: current30d?.sessions ?? 0,
58727
58802
  socialSessionsPrev30d: prev30d?.sessions ?? 0,
58728
58803
  trend30dPct: deltaPercent(current30d?.sessions ?? 0, prev30d?.sessions ?? 0),
58729
- biggestMover
58804
+ biggestMover: findBiggestMover(sourceCurrent, sourcePrev)
58730
58805
  };
58806
+ return response;
58731
58807
  });
58732
58808
  app.get("/projects/:name/ga/attribution-trend", async (request, _reply) => {
58733
58809
  const project = resolveProject(app.db, request.params.name);
@@ -58769,23 +58845,9 @@ async function ga4Routes(app, opts) {
58769
58845
  sql21`${gaAiReferrals.date} < ${daysAgo(7)}`,
58770
58846
  eq55(gaAiReferrals.sourceDimension, "session")
58771
58847
  )).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
58848
  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
58849
  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 {
58850
+ const response = {
58789
58851
  total: buildTrend(sumTotal),
58790
58852
  organic: buildTrend(sumOrganic),
58791
58853
  ai: buildTrend(sumAi),
@@ -58794,6 +58856,7 @@ async function ga4Routes(app, opts) {
58794
58856
  aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
58795
58857
  socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
58796
58858
  };
58859
+ return response;
58797
58860
  });
58798
58861
  app.get("/projects/:name/ga/session-history", async (request, _reply) => {
58799
58862
  const project = resolveProject(app.db, request.params.name);
@@ -82342,9 +82405,6 @@ function loadProject(ctx) {
82342
82405
  if (!ctx.project) return null;
82343
82406
  return ctx.db.select().from(projects).where(eq66(projects.id, ctx.project.id)).get() ?? null;
82344
82407
  }
82345
- function percent(value) {
82346
- return Math.round(value * 100);
82347
- }
82348
82408
  var winnabilityCoverageCheck = {
82349
82409
  id: "content.winnability.coverage",
82350
82410
  category: CheckCategories.integrations,
@@ -82412,7 +82472,7 @@ var winnabilityCoverageCheck = {
82412
82472
  return {
82413
82473
  status: CheckStatuses.warn,
82414
82474
  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.`,
82475
+ 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
82476
  remediation: discoverRemediation,
82417
82477
  details
82418
82478
  };
@@ -82420,7 +82480,7 @@ var winnabilityCoverageCheck = {
82420
82480
  return {
82421
82481
  status: CheckStatuses.ok,
82422
82482
  code: "content.winnability.covered",
82423
- summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${percent(coverage)}%); the winnability gate is active.`,
82483
+ summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) recognized (${formatPercent(coverage)}); the winnability gate is active.`,
82424
82484
  remediation: null,
82425
82485
  details
82426
82486
  };
@@ -86113,6 +86173,9 @@ function mapCrawlPage(row) {
86113
86173
  healthState: deriveSiteHealthState(row)
86114
86174
  };
86115
86175
  }
86176
+ function withRecordedShare(factor) {
86177
+ return { ...factor, sharePct: typeof factor.sharePct === "number" ? factor.sharePct : null };
86178
+ }
86116
86179
  function mapCrawlPageAuditEvidence(row) {
86117
86180
  const fields = row.auditFields;
86118
86181
  const rawFactors = Array.isArray(fields.factors) ? fields.factors : [];
@@ -86399,7 +86462,7 @@ async function technicalAeoRoutes(app, opts) {
86399
86462
  trend,
86400
86463
  previousScore: previous?.aggregateScore ?? null,
86401
86464
  previousAuditedAt: previous?.auditedAt ?? null,
86402
- factors: snap.factorAverages,
86465
+ factors: snap.factorAverages.map(withRecordedShare),
86403
86466
  crossCuttingIssues: snap.crossCuttingIssues,
86404
86467
  prioritizedFixes: snap.prioritizedFixes
86405
86468
  };
@@ -86436,7 +86499,7 @@ async function technicalAeoRoutes(app, opts) {
86436
86499
  overallScore: row.overallScore,
86437
86500
  status: row.status === "error" ? "error" : "success",
86438
86501
  error: row.error,
86439
- factors: row.factors
86502
+ factors: row.factors.map(withRecordedShare)
86440
86503
  }));
86441
86504
  return { project: project.name, runId: latest.runId, auditedAt: latest.auditedAt, total, pages };
86442
86505
  });
@@ -90164,6 +90227,7 @@ export {
90164
90227
  deliverWebhook,
90165
90228
  redactNotificationUrl,
90166
90229
  renderReportHtml,
90230
+ buildOpenApiDocument,
90167
90231
  toAlertView,
90168
90232
  resolveDestination,
90169
90233
  getPlaceDetails,