@ainyc/canonry 4.12.1 → 4.13.3

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.
@@ -4,7 +4,7 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-LNRDWAG3.js";
7
+ } from "./chunk-5NYG5EC7.js";
8
8
  import {
9
9
  DEFAULT_RUN_HISTORY_LIMIT,
10
10
  IntelligenceService,
@@ -65,7 +65,7 @@ import {
65
65
  schedules,
66
66
  trafficSources,
67
67
  usageCounters
68
- } from "./chunk-DCE3B6KD.js";
68
+ } from "./chunk-7HBZCGRL.js";
69
69
  import {
70
70
  AGENT_MEMORY_VALUE_MAX_BYTES,
71
71
  AGENT_PROVIDER_IDS,
@@ -108,6 +108,11 @@ import {
108
108
  emptyCitationVisibility,
109
109
  extractAnswerMentions,
110
110
  findDuplicateLocationLabels,
111
+ formatDate,
112
+ formatDateRange,
113
+ formatIsoDate,
114
+ formatNumber,
115
+ formatRatio,
111
116
  getProviderLocationHandling,
112
117
  hasLocationLabel,
113
118
  internalError,
@@ -146,7 +151,7 @@ import {
146
151
  visibilityStateFromAnswerMentioned,
147
152
  windowCutoff,
148
153
  wordpressEnvSchema
149
- } from "./chunk-YDGT5CAY.js";
154
+ } from "./chunk-6QTH5NS5.js";
150
155
 
151
156
  // src/telemetry.ts
152
157
  import crypto from "crypto";
@@ -2584,16 +2589,6 @@ var COLORS = {
2584
2589
  function escapeHtml(value) {
2585
2590
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2586
2591
  }
2587
- function formatRatio(value) {
2588
- if (!Number.isFinite(value) || value === 0) return "0%";
2589
- return `${(value * 100).toFixed(1)}%`;
2590
- }
2591
- function formatNumber(value) {
2592
- if (!Number.isFinite(value)) return "\u2014";
2593
- if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
2594
- if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
2595
- return value.toLocaleString("en-US");
2596
- }
2597
2592
  function summarizeQueryParams(params) {
2598
2593
  const keys = Array.from(params.keys());
2599
2594
  const total = keys.length;
@@ -2636,23 +2631,6 @@ function formatLandingPageHtml(raw) {
2636
2631
  if (!summary) return pathHtml;
2637
2632
  return `${pathHtml}<span class="page-query" title="${escapeHtml(value)}">${escapeHtml(summary)}</span>`;
2638
2633
  }
2639
- function formatDate(iso) {
2640
- if (!iso) return "\u2014";
2641
- try {
2642
- const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
2643
- const options = { month: "short", day: "numeric", year: "numeric" };
2644
- const d = dateOnly && dateOnly[1] && dateOnly[2] && dateOnly[3] ? new Date(Date.UTC(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]))) : new Date(iso);
2645
- if (Number.isNaN(d.getTime())) return iso;
2646
- return d.toLocaleDateString("en-US", dateOnly ? { ...options, timeZone: "UTC" } : options);
2647
- } catch {
2648
- return iso;
2649
- }
2650
- }
2651
- function formatDateRange(start, end) {
2652
- if (!start && !end) return "";
2653
- if (start && end) return `${formatDate(start)} \u2192 ${formatDate(end)}`;
2654
- return formatDate(start || end);
2655
- }
2656
2634
  function gscDateRange(report) {
2657
2635
  const summary = report.executiveSummary.gsc;
2658
2636
  const gsc = report.gsc;
@@ -2663,6 +2641,47 @@ function gscDateRange(report) {
2663
2641
  function pluralize(count, singular, plural = `${singular}s`) {
2664
2642
  return count === 1 ? singular : plural;
2665
2643
  }
2644
+ var PROVIDER_DISPLAY_NAMES = {
2645
+ gemini: "Gemini",
2646
+ openai: "ChatGPT",
2647
+ claude: "Claude",
2648
+ perplexity: "Perplexity",
2649
+ local: "Local model",
2650
+ "cdp:chatgpt": "ChatGPT (browser)"
2651
+ };
2652
+ function providerDisplayName(name) {
2653
+ return PROVIDER_DISPLAY_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1);
2654
+ }
2655
+ function clientHorizonLabel(horizon) {
2656
+ switch (horizon) {
2657
+ case "immediate":
2658
+ return "Do now";
2659
+ case "short-term":
2660
+ return "This month";
2661
+ case "medium-term":
2662
+ return "Next quarter";
2663
+ }
2664
+ }
2665
+ function clientConfidenceLabel(confidence) {
2666
+ switch (confidence) {
2667
+ case "high":
2668
+ return "Strong evidence";
2669
+ case "medium":
2670
+ return "Some evidence";
2671
+ case "low":
2672
+ return "Worth trying";
2673
+ }
2674
+ }
2675
+ function clientTrendCopy(delta) {
2676
+ if (!delta) return null;
2677
+ if (delta.direction === "up") {
2678
+ return { text: `Up ${delta.deltaAbs.toFixed(1)} points since last check (was ${delta.prior}%)`, tone: "positive", arrow: "\u2191" };
2679
+ }
2680
+ if (delta.direction === "down") {
2681
+ return { text: `Down ${Math.abs(delta.deltaAbs).toFixed(1)} points since last check (was ${delta.prior}%)`, tone: "negative", arrow: "\u2193" };
2682
+ }
2683
+ return { text: `Holding steady since last check (was ${delta.prior}%)`, tone: "neutral", arrow: "\u2192" };
2684
+ }
2666
2685
  function compactInlineList(items, limit = 3) {
2667
2686
  const visible = items.slice(0, limit);
2668
2687
  const more = items.length - visible.length;
@@ -3192,6 +3211,222 @@ table.report-table td .badge {
3192
3211
  color: ${COLORS.textFaint};
3193
3212
  font-size: 12px;
3194
3213
  }
3214
+ .client-hero {
3215
+ background: ${COLORS.surface};
3216
+ border: 1px solid ${COLORS.border};
3217
+ border-radius: 16px;
3218
+ padding: 32px;
3219
+ margin-bottom: 24px;
3220
+ }
3221
+ .client-hero .client-hero-eyebrow {
3222
+ text-transform: uppercase;
3223
+ letter-spacing: 0.05em;
3224
+ font-size: 11px;
3225
+ font-weight: 600;
3226
+ color: ${COLORS.textFaint};
3227
+ }
3228
+ .client-hero .client-hero-number {
3229
+ font-size: 80px;
3230
+ line-height: 1;
3231
+ font-weight: 800;
3232
+ letter-spacing: -0.02em;
3233
+ color: ${COLORS.text};
3234
+ margin: 14px 0 18px;
3235
+ }
3236
+ .client-hero .client-hero-sentence {
3237
+ font-size: 17px;
3238
+ color: #d4d4d8;
3239
+ max-width: 720px;
3240
+ margin: 0;
3241
+ }
3242
+ .client-hero .client-hero-trend {
3243
+ margin-top: 14px;
3244
+ font-size: 14px;
3245
+ font-weight: 500;
3246
+ }
3247
+ .client-hero .client-hero-trend.tone-positive { color: ${COLORS.positive}; }
3248
+ .client-hero .client-hero-trend.tone-negative { color: ${COLORS.negative}; }
3249
+ .client-hero .client-hero-trend.tone-neutral { color: ${COLORS.textMuted}; }
3250
+ .client-metric-grid {
3251
+ display: grid;
3252
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
3253
+ gap: 16px;
3254
+ margin-bottom: 24px;
3255
+ }
3256
+ .client-metric-tile {
3257
+ background: ${COLORS.surface};
3258
+ border: 1px solid ${COLORS.border};
3259
+ border-radius: 12px;
3260
+ padding: 22px 24px;
3261
+ }
3262
+ .client-metric-tile .label {
3263
+ text-transform: uppercase;
3264
+ letter-spacing: 0.05em;
3265
+ font-size: 11px;
3266
+ font-weight: 600;
3267
+ color: ${COLORS.textFaint};
3268
+ margin-bottom: 14px;
3269
+ }
3270
+ .client-metric-tile .value {
3271
+ font-size: 48px;
3272
+ line-height: 1;
3273
+ font-weight: 800;
3274
+ letter-spacing: -0.02em;
3275
+ color: ${COLORS.text};
3276
+ }
3277
+ .client-metric-tile .subtitle {
3278
+ margin-top: 10px;
3279
+ font-size: 12px;
3280
+ color: ${COLORS.textMuted};
3281
+ }
3282
+ .client-card {
3283
+ background: ${COLORS.surface};
3284
+ border: 1px solid ${COLORS.border};
3285
+ border-radius: 12px;
3286
+ padding: 22px 24px;
3287
+ margin-bottom: 16px;
3288
+ }
3289
+ .client-card h3 {
3290
+ font-size: 15px;
3291
+ font-weight: 600;
3292
+ margin: 0 0 4px;
3293
+ }
3294
+ .client-card .card-subtitle {
3295
+ font-size: 12px;
3296
+ color: ${COLORS.textMuted};
3297
+ margin: 0 0 18px;
3298
+ }
3299
+ .client-bar-list {
3300
+ display: flex;
3301
+ flex-direction: column;
3302
+ gap: 14px;
3303
+ }
3304
+ .client-bar-row {
3305
+ display: grid;
3306
+ grid-template-columns: 140px 1fr 130px;
3307
+ align-items: center;
3308
+ gap: 14px;
3309
+ font-size: 13px;
3310
+ }
3311
+ .client-bar-row .bar-label { color: #d4d4d8; }
3312
+ .client-bar-row .bar-track {
3313
+ height: 10px;
3314
+ background: ${COLORS.border};
3315
+ border-radius: 999px;
3316
+ overflow: hidden;
3317
+ }
3318
+ .client-bar-row .bar-fill {
3319
+ height: 100%;
3320
+ border-radius: 999px;
3321
+ background: ${COLORS.positive}b3;
3322
+ }
3323
+ .client-bar-row .bar-fill.bar-fill-neutral { background: #a1a1aaaa; }
3324
+ .client-bar-row .bar-fill.bar-fill-sky { background: #38bdf8b3; }
3325
+ .client-bar-row .bar-value {
3326
+ text-align: right;
3327
+ font-size: 13px;
3328
+ font-weight: 600;
3329
+ color: ${COLORS.text};
3330
+ font-variant-numeric: tabular-nums;
3331
+ }
3332
+ .client-bar-row .bar-value-sub { color: ${COLORS.textFaint}; font-weight: 400; }
3333
+ .client-progress-number {
3334
+ font-size: 56px;
3335
+ font-weight: 800;
3336
+ line-height: 1;
3337
+ letter-spacing: -0.02em;
3338
+ margin: 12px 0 4px;
3339
+ }
3340
+ .client-progress-number.tone-positive { color: ${COLORS.positive}; }
3341
+ .client-progress-number.tone-caution { color: ${COLORS.caution}; }
3342
+ .client-progress-number.tone-negative { color: ${COLORS.negative}; }
3343
+ .client-progress-bar {
3344
+ height: 12px;
3345
+ background: ${COLORS.border};
3346
+ border-radius: 999px;
3347
+ overflow: hidden;
3348
+ margin: 12px 0 14px;
3349
+ }
3350
+ .client-progress-fill { height: 100%; border-radius: 999px; }
3351
+ .client-progress-fill.tone-positive { background: ${COLORS.positive}b3; }
3352
+ .client-progress-fill.tone-caution { background: ${COLORS.caution}b3; }
3353
+ .client-progress-fill.tone-negative { background: ${COLORS.negative}b3; }
3354
+ .client-evidence-grid {
3355
+ display: grid;
3356
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
3357
+ gap: 16px;
3358
+ }
3359
+ .client-opportunity-list {
3360
+ display: flex;
3361
+ flex-direction: column;
3362
+ gap: 8px;
3363
+ margin: 0;
3364
+ padding: 0;
3365
+ list-style: none;
3366
+ }
3367
+ .client-opportunity-list li {
3368
+ background: #09090b;
3369
+ border: 1px solid ${COLORS.border};
3370
+ border-radius: 8px;
3371
+ padding: 10px 14px;
3372
+ }
3373
+ .client-opportunity-list li .op-query {
3374
+ font-weight: 500;
3375
+ color: ${COLORS.text};
3376
+ font-size: 13px;
3377
+ }
3378
+ .client-opportunity-list li .op-action {
3379
+ margin-top: 2px;
3380
+ font-size: 11px;
3381
+ color: ${COLORS.textMuted};
3382
+ }
3383
+ .client-confidence-note {
3384
+ background: ${COLORS.surface};
3385
+ border: 1px solid ${COLORS.border};
3386
+ border-radius: 8px;
3387
+ padding: 10px 14px;
3388
+ font-size: 12px;
3389
+ color: ${COLORS.textMuted};
3390
+ margin-bottom: 6px;
3391
+ }
3392
+ .client-explainer {
3393
+ background: #09090b;
3394
+ border: 1px solid ${COLORS.border};
3395
+ border-radius: 12px;
3396
+ padding: 12px 16px;
3397
+ font-size: 12px;
3398
+ color: ${COLORS.textMuted};
3399
+ margin-bottom: 16px;
3400
+ line-height: 1.6;
3401
+ }
3402
+ .client-explainer strong { color: ${COLORS.text}; }
3403
+ .client-explainer .term { color: #d4d4d8; font-weight: 500; }
3404
+ .client-questions-list {
3405
+ display: grid;
3406
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
3407
+ gap: 8px;
3408
+ margin: 0;
3409
+ padding: 0;
3410
+ list-style: none;
3411
+ }
3412
+ .client-questions-list li {
3413
+ display: flex;
3414
+ align-items: flex-start;
3415
+ gap: 12px;
3416
+ background: #09090b;
3417
+ border: 1px solid ${COLORS.border};
3418
+ border-radius: 8px;
3419
+ padding: 10px 14px;
3420
+ font-size: 13px;
3421
+ color: #d4d4d8;
3422
+ }
3423
+ .client-questions-list li .qnum {
3424
+ flex-shrink: 0;
3425
+ font-size: 11px;
3426
+ font-weight: 600;
3427
+ color: ${COLORS.textFaint};
3428
+ font-variant-numeric: tabular-nums;
3429
+ }
3195
3430
  @media (max-width: 760px) {
3196
3431
  .container { padding: 32px 16px 72px; }
3197
3432
  .executive-hero { grid-template-columns: 1fr; }
@@ -3199,10 +3434,37 @@ table.report-table td .badge {
3199
3434
  .source-bar-row { grid-template-columns: 1fr; gap: 6px; }
3200
3435
  .source-bar-value { text-align: left; }
3201
3436
  .chart-grid { grid-template-columns: 1fr; }
3437
+ .client-hero .client-hero-number { font-size: 56px; }
3438
+ .client-metric-tile .value { font-size: 36px; }
3439
+ .client-bar-row { grid-template-columns: 100px 1fr 100px; gap: 10px; }
3202
3440
  }
3203
3441
  @media print {
3204
- body { background: white; color: black; }
3205
- section.report-section { break-inside: avoid; }
3442
+ @page { margin: 0.5in; }
3443
+ html, body {
3444
+ background: ${COLORS.bg};
3445
+ color: ${COLORS.text};
3446
+ -webkit-print-color-adjust: exact;
3447
+ print-color-adjust: exact;
3448
+ }
3449
+ .container { max-width: none; padding: 0; }
3450
+ section.report-section,
3451
+ .executive-hero,
3452
+ .headline-card,
3453
+ .hero-proof,
3454
+ .client-hero,
3455
+ .client-metric-tile,
3456
+ .client-card,
3457
+ .client-note,
3458
+ .chart-card,
3459
+ .action-card,
3460
+ .insight-card,
3461
+ .source-bar-row,
3462
+ .client-bar-row,
3463
+ tr,
3464
+ table { break-inside: avoid; }
3465
+ h1, h2, h3, .eyebrow { break-after: avoid; }
3466
+ .footer { margin-top: 32px; }
3467
+ .footer a { color: ${COLORS.text}; }
3206
3468
  }
3207
3469
  `;
3208
3470
  function section(opts, body) {
@@ -3389,68 +3651,82 @@ function renderTrafficDeltaTile(label, delta, countLabel) {
3389
3651
  </div>`;
3390
3652
  }
3391
3653
  var WHATS_CHANGED_PERIOD_DAYS = 14;
3392
- function renderProviderMovements(movements) {
3654
+ function renderProviderMovements(movements, audience) {
3393
3655
  const meaningful = movements.filter((m) => m.direction !== "flat");
3394
3656
  if (meaningful.length === 0) return "";
3657
+ const isClient = audience === "client";
3395
3658
  const rows = meaningful.map((m) => {
3396
3659
  const sign = m.deltaAbs > 0 ? "+" : "";
3397
3660
  return `<tr>
3398
- <td>${escapeHtml(m.provider)}</td>
3661
+ <td>${escapeHtml(isClient ? providerDisplayName(m.provider) : m.provider)}</td>
3399
3662
  <td class="numeric">${m.prior}%</td>
3400
3663
  <td class="numeric">${m.current}%</td>
3401
3664
  <td class="numeric ${deltaToneClass(m.direction)}">${sign}${m.deltaAbs.toFixed(1)}% ${deltaArrow(m.direction)}</td>
3402
3665
  </tr>`;
3403
3666
  }).join("");
3404
- return `<div class="chart-card"><h3>AI engine movements</h3>
3667
+ const heading = isClient ? "How each AI tool changed" : "AI engine movements";
3668
+ const colA = isClient ? "AI tool" : "Engine";
3669
+ const colB = isClient ? "Was" : "Prior";
3670
+ const colC = isClient ? "Now" : "Current";
3671
+ return `<div class="chart-card"><h3>${heading}</h3>
3405
3672
  <table class="report-table">
3406
- <thead><tr><th>Engine</th><th class="numeric">Prior</th><th class="numeric">Current</th><th class="numeric">Change</th></tr></thead>
3673
+ <thead><tr><th>${colA}</th><th class="numeric">${colB}</th><th class="numeric">${colC}</th><th class="numeric">Change</th></tr></thead>
3407
3674
  <tbody>${rows}</tbody>
3408
3675
  </table>
3409
3676
  </div>`;
3410
3677
  }
3411
- function renderWinsLosses(insights2, heading, emptyMessage) {
3678
+ function renderWinsLosses(insights2, heading, emptyMessage, audience) {
3412
3679
  if (insights2.length === 0) {
3413
3680
  return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
3414
3681
  <p class="section-intro">${escapeHtml(emptyMessage)}</p>
3415
3682
  </div>`;
3416
3683
  }
3684
+ const isClient = audience === "client";
3417
3685
  const rows = insights2.map((i) => {
3418
3686
  const tone = severityTone(i.severity);
3419
3687
  const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">\xD7 ${i.instanceCount}</span>` : "";
3688
+ const severityCell = isClient ? "" : `<td><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>`;
3420
3689
  return `<tr>
3421
- <td><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
3690
+ ${severityCell}
3422
3691
  <td>${escapeHtml(i.title)}${countChip}</td>
3423
3692
  <td>${escapeHtml(i.query)}</td>
3424
- <td>${escapeHtml(i.provider)}</td>
3693
+ <td>${escapeHtml(isClient ? providerDisplayName(i.provider) : i.provider)}</td>
3425
3694
  </tr>`;
3426
3695
  }).join("");
3696
+ const headers = isClient ? `<tr><th>What changed</th><th>Customer question</th><th>AI tool</th></tr>` : `<tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th></tr>`;
3427
3697
  return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
3428
3698
  <table class="report-table">
3429
- <thead><tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th></tr></thead>
3699
+ <thead>${headers}</thead>
3430
3700
  <tbody>${rows}</tbody>
3431
3701
  </table>
3432
3702
  </div>`;
3433
3703
  }
3434
- function renderWhatsChanged(report) {
3704
+ function renderWhatsChanged(report, audience) {
3435
3705
  const w = report.whatsChanged;
3706
+ const isClient = audience === "client";
3707
+ const eyebrow = isClient ? "Since last check" : "Section 2";
3708
+ const title = isClient ? "What's different since last check" : "What's Changed";
3709
+ const intro = isClient ? "" : w.headline;
3436
3710
  if (!w.enoughHistory && !w.gscClicksDelta && !w.aiReferralsDelta && w.wins.length === 0 && w.regressions.length === 0) {
3437
3711
  return section(
3438
- { id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
3439
- renderEmpty("Trends will appear after a few more checks.")
3712
+ { id: "whats-changed", eyebrow, title, intro },
3713
+ renderEmpty(isClient ? "No comparison yet \u2014 trends will appear after a few more checks." : "Trends will appear after a few more checks.")
3440
3714
  );
3441
3715
  }
3442
3716
  const rateTiles = `<div class="metric-grid">
3443
- ${renderRateDeltaTile("Citation rate", w.citationRate, "%")}
3444
- ${renderRateDeltaTile("Mention rate", w.mentionRate, "%")}
3445
- ${renderRateDeltaTile("Cited queries", w.citedQueryCount, "count")}
3446
- ${renderTrafficDeltaTile("GSC clicks", w.gscClicksDelta, "clicks")}
3447
- ${renderTrafficDeltaTile("AI referral sessions", w.aiReferralsDelta, "sessions")}
3717
+ ${renderRateDeltaTile(isClient ? "AI links to your website" : "Citation rate", w.citationRate, "%")}
3718
+ ${renderRateDeltaTile(isClient ? "AI mentions your name" : "Mention rate", w.mentionRate, "%")}
3719
+ ${renderRateDeltaTile(isClient ? "Questions AI answered with you" : "Cited queries", w.citedQueryCount, "count")}
3720
+ ${renderTrafficDeltaTile(isClient ? "Visitors from Google" : "GSC clicks", w.gscClicksDelta, isClient ? "visits" : "clicks")}
3721
+ ${renderTrafficDeltaTile(isClient ? "Visitors from AI tools" : "AI referral sessions", w.aiReferralsDelta, isClient ? "visits" : "sessions")}
3448
3722
  </div>`;
3449
- const movements = renderProviderMovements(w.providerMovements);
3450
- const wins = renderWinsLosses(w.wins, "Wins", "No new gains in the latest check.");
3451
- const regressions = renderWinsLosses(w.regressions, "Regressions", "No new regressions in the latest check.");
3723
+ const movements = renderProviderMovements(w.providerMovements, audience);
3724
+ const winsHeading = isClient ? "What got better" : "Wins";
3725
+ const lossesHeading = isClient ? "What got worse" : "Regressions";
3726
+ const wins = renderWinsLosses(w.wins, winsHeading, isClient ? "No new wins this period." : "No new gains in the latest check.", audience);
3727
+ const regressions = renderWinsLosses(w.regressions, lossesHeading, isClient ? "Nothing got worse this period." : "No new regressions in the latest check.", audience);
3452
3728
  return section(
3453
- { id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
3729
+ { id: "whats-changed", eyebrow, title, intro },
3454
3730
  `${rateTiles}${movements}${wins}${regressions}`
3455
3731
  );
3456
3732
  }
@@ -4104,8 +4380,9 @@ function renderRecommendedNextSteps(report) {
4104
4380
  function actionAudienceMatches(action, audience) {
4105
4381
  return action.audience === "both" || action.audience === audience;
4106
4382
  }
4107
- function renderActionCards(actions) {
4108
- if (actions.length === 0) return renderEmpty("No prioritized actions yet.");
4383
+ function renderActionCards(actions, audience) {
4384
+ const isClient = audience === "client";
4385
+ if (actions.length === 0) return renderEmpty(isClient ? "No recommendations yet \u2014 run an AI check to populate this." : "No prioritized actions yet.");
4109
4386
  return `<div class="action-card-grid">
4110
4387
  ${actions.map((action, idx) => {
4111
4388
  const tone = reportActionTone(action);
@@ -4113,18 +4390,22 @@ function renderActionCards(actions) {
4113
4390
  const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
4114
4391
  const proof = renderProofChips(action.evidence.length > 0 ? action.evidence : action.why, 3);
4115
4392
  const details = why || evidence ? `<details class="action-details">
4116
- <summary>Evidence details</summary>
4117
- ${why ? `<div><strong>Why</strong>${why}</div>` : ""}
4118
- ${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
4393
+ <summary>${isClient ? "See the data behind this" : "Evidence details"}</summary>
4394
+ ${why ? `<div><strong>${isClient ? "Why this matters" : "Why"}</strong>${why}</div>` : ""}
4395
+ ${evidence ? `<div><strong>${isClient ? "What we saw" : "Evidence"}</strong>${evidence}</div>` : ""}
4119
4396
  </details>` : "";
4397
+ const horizonLabel = isClient ? clientHorizonLabel(action.horizon) : reportHorizonLabel(action.horizon);
4398
+ const confidenceLabel = isClient ? clientConfidenceLabel(action.confidence) : `${reportConfidenceLabel(action.confidence)} confidence`;
4399
+ const categoryBadge = isClient ? "" : `<span class="badge tone-neutral">${escapeHtml(reportActionCategoryLabel(action.category))}</span>`;
4400
+ const successLabel = isClient ? "What success looks like:" : "Win condition:";
4120
4401
  return `<article class="action-card">
4121
4402
  <div class="action-head">
4122
- <div class="action-rank" title="Impact rank \u2014 1 is the highest-leverage action">${idx + 1}</div>
4403
+ <div class="action-rank" title="${isClient ? "Priority \u2014 1 will move the needle fastest" : "Impact rank \u2014 1 is the highest-leverage action"}">${idx + 1}</div>
4123
4404
  <div>
4124
4405
  <div class="action-meta">
4125
- <span class="badge tone-${tone}">${escapeHtml(reportHorizonLabel(action.horizon))}</span>
4126
- <span class="badge tone-neutral">${escapeHtml(reportActionCategoryLabel(action.category))}</span>
4127
- <span class="badge tone-neutral">${escapeHtml(reportConfidenceLabel(action.confidence))} confidence</span>
4406
+ <span class="badge tone-${tone}">${escapeHtml(horizonLabel)}</span>
4407
+ ${categoryBadge}
4408
+ <span class="badge tone-neutral">${escapeHtml(confidenceLabel)}</span>
4128
4409
  </div>
4129
4410
  <h3>${escapeHtml(action.title)}</h3>
4130
4411
  </div>
@@ -4132,7 +4413,7 @@ function renderActionCards(actions) {
4132
4413
  <p>${escapeHtml(action.action)}</p>
4133
4414
  ${proof}
4134
4415
  ${details}
4135
- <div class="success-metric"><strong>Win condition:</strong> ${escapeHtml(action.successMetric)}</div>
4416
+ <div class="success-metric"><strong>${successLabel}</strong> ${escapeHtml(action.successMetric)}</div>
4136
4417
  </article>`;
4137
4418
  }).join("")}
4138
4419
  </div>`;
@@ -4143,76 +4424,150 @@ function renderAudienceActionPlan(report, audience) {
4143
4424
  return section(
4144
4425
  {
4145
4426
  id: audience === "client" ? "client-action-plan" : "agency-action-plan",
4146
- eyebrow: audience === "client" ? "Client actions" : "Agency actions",
4147
- title: audience === "client" ? "What We Recommend Next" : "Agency Action Plan",
4148
- intro: audience === "client" ? "The short list to approve and execute." : "The highest-leverage work, sorted by urgency and evidence strength."
4427
+ eyebrow: audience === "client" ? "Action plan" : "Agency actions",
4428
+ title: audience === "client" ? "What to do next" : "Agency Action Plan",
4429
+ intro: audience === "client" ? "Approve these in order. They are sorted by what will move the needle fastest." : "The highest-leverage work, sorted by urgency and evidence strength."
4149
4430
  },
4150
- renderActionCards(actions)
4431
+ renderActionCards(actions, audience)
4151
4432
  );
4152
4433
  }
4153
4434
  function renderClientSummary(report) {
4154
4435
  const s = report.executiveSummary;
4155
- const metrics = `<div class="metric-grid">
4156
- <div class="metric"><div class="label">Citation coverage</div><div class="value">${s.citationRate}%</div><div class="delta">${s.citedQueryCount}/${s.totalQueryCount} tracked queries cited</div></div>
4157
- <div class="metric"><div class="label">Mention coverage</div><div class="value">${s.mentionRate}%</div><div class="delta">${s.mentionedQueryCount}/${s.totalQueryCount} tracked queries mentioned</div></div>
4158
- <div class="metric"><div class="label">Providers checked</div><div class="value">${formatNumber(s.providerCount)}</div><div class="delta">${formatNumber(s.queryCount)} tracked queries</div></div>
4436
+ const sc = report.citationScorecard;
4437
+ const totalQ = s.totalQueryCount;
4438
+ const heroNumber = totalQ > 0 ? `${s.citationRate}%` : "\u2014";
4439
+ const heroSentence = totalQ > 0 ? `When customers asked AI ${totalQ} ${pluralize(totalQ, "question")} about your industry, AI linked to your website in ${s.citedQueryCount} of ${totalQ === 1 ? "them" : "those answers"}.` : "No AI check has been run yet. Run a check to see how AI tools answer customer questions about your business.";
4440
+ const trend = clientTrendCopy(report.whatsChanged.citationRate);
4441
+ const heroTrend = trend ? `<p class="client-hero-trend tone-${trend.tone}"><span style="margin-right:6px;">${trend.arrow}</span>${escapeHtml(trend.text)}</p>` : "";
4442
+ const hero = `<div class="client-hero">
4443
+ <div class="client-hero-eyebrow">Overview</div>
4444
+ <div class="client-hero-number">${heroNumber}</div>
4445
+ <p class="client-hero-sentence">${escapeHtml(heroSentence)}</p>
4446
+ ${heroTrend}
4159
4447
  </div>`;
4160
- const notes = report.clientSummary.confidenceNotes.length > 0 ? `<div class="client-notes">${report.clientSummary.confidenceNotes.map((note) => `<div class="client-note">${escapeHtml(note)}</div>`).join("")}</div>` : "";
4161
- return section(
4162
- {
4163
- id: "client-summary",
4164
- eyebrow: "Client summary",
4165
- title: "How You're Appearing",
4166
- intro: report.clientSummary.overview
4167
- },
4168
- `<div class="chart-card">
4169
- <h3>${escapeHtml(report.clientSummary.headline)}</h3>
4170
- <p class="source-origin-headline">${escapeHtml(report.clientSummary.overview)}</p>
4448
+ const providerSubtitle = sc.providers.length > 0 ? sc.providers.map(providerDisplayName).join(", ") : `${formatNumber(s.queryCount)} ${pluralize(s.queryCount, "question")} tested`;
4449
+ const tiles = `<div class="client-metric-grid">
4450
+ <div class="client-metric-tile">
4451
+ <div class="label">AI mentions your name</div>
4452
+ <div class="value">${s.mentionRate}%</div>
4453
+ <div class="subtitle">${totalQ > 0 ? `Says your name in ${s.mentionedQueryCount} of ${totalQ} ${pluralize(totalQ, "answer")}` : "No data yet"}</div>
4171
4454
  </div>
4172
- ${metrics}
4173
- ${notes}`
4174
- );
4455
+ <div class="client-metric-tile">
4456
+ <div class="label">AI links to your website</div>
4457
+ <div class="value">${s.citationRate}%</div>
4458
+ <div class="subtitle">${totalQ > 0 ? `Cites your site as a source in ${s.citedQueryCount} of ${totalQ} ${pluralize(totalQ, "answer")}` : "No data yet"}</div>
4459
+ </div>
4460
+ <div class="client-metric-tile">
4461
+ <div class="label">AI tools tested</div>
4462
+ <div class="value">${formatNumber(s.providerCount)}</div>
4463
+ <div class="subtitle">${escapeHtml(providerSubtitle)}</div>
4464
+ </div>
4465
+ </div>`;
4466
+ const explainer = `<div class="client-explainer">
4467
+ <strong>Mentions and links are different.</strong>
4468
+ A <span class="term">mention</span> is when AI says your name out loud in its answer.
4469
+ A <span class="term">link</span> is when AI lists your website as a source it used.
4470
+ AI can do either, both, or neither \u2014 that's why we track both.
4471
+ </div>`;
4472
+ const questions = sc.queries.length > 0 ? `<div class="client-card">
4473
+ <h3>Customer questions we tested</h3>
4474
+ <p class="card-subtitle">These are the ${sc.queries.length} ${pluralize(sc.queries.length, "question we asked", "questions we asked")} every AI tool. The numbers above measure how often you came up.</p>
4475
+ <ol class="client-questions-list">
4476
+ ${sc.queries.map((q, i) => `<li><span class="qnum">${String(i + 1).padStart(2, "0")}</span><span>"${escapeHtml(q)}"</span></li>`).join("")}
4477
+ </ol>
4478
+ </div>` : "";
4479
+ const providerBars = sc.providerRates.length > 0 ? `<div class="client-card">
4480
+ <h3>How often each AI tool links to your website</h3>
4481
+ <p class="card-subtitle">Higher is better. Each bar shows the share of customer questions where the AI cited your site.</p>
4482
+ <div class="client-bar-list">
4483
+ ${sc.providerRates.map((r) => {
4484
+ const pct = Math.max(r.citationRate, 1.5);
4485
+ return `<div class="client-bar-row">
4486
+ <span class="bar-label">${escapeHtml(providerDisplayName(r.provider))}</span>
4487
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
4488
+ <span class="bar-value">${r.citationRate}% <span class="bar-value-sub">(${r.citedCount}/${r.totalCount})</span></span>
4489
+ </div>`;
4490
+ }).join("")}
4491
+ </div>
4492
+ </div>` : "";
4493
+ const notes = report.clientSummary.confidenceNotes.length > 0 ? `<div>${report.clientSummary.confidenceNotes.map((note) => `<div class="client-confidence-note">${escapeHtml(note)}</div>`).join("")}</div>` : "";
4494
+ return `<section class="report-section" id="client-summary">${hero}${tiles}${explainer}${questions}${providerBars}${notes}</section>`;
4175
4495
  }
4176
4496
  function renderClientEvidenceSummary(report) {
4177
- const evidenceCards = [];
4178
- if (report.aiSourceOrigin.topDomains.length > 0) {
4179
- evidenceCards.push(`<div class="diagnostic-card tone-neutral">
4180
- <h3>Sources AI engines trust</h3>
4181
- <p>These domains appeared most often as cited sources outside your owned domain.</p>
4182
- <ul>${report.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `<li>${escapeHtml(d.domain)}: ${formatNumber(d.count)} citation${d.count === 1 ? "" : "s"}</li>`).join("")}</ul>
4497
+ const ai = report.aiSourceOrigin.topDomains.slice(0, 5);
4498
+ const gsc = report.gsc;
4499
+ const indexing = report.indexingHealth;
4500
+ const opportunities = dedupeReportOpportunities(report).slice(0, 5);
4501
+ const aiMax = ai.length > 0 ? Math.max(...ai.map((d) => d.count)) : 0;
4502
+ const gscMax = gsc ? Math.max(...gsc.topQueries.slice(0, 5).map((q) => q.impressions), 1) : 0;
4503
+ const cards = [];
4504
+ if (ai.length > 0) {
4505
+ cards.push(`<div class="client-card">
4506
+ <h3>Where AI gets its answers</h3>
4507
+ <p class="card-subtitle">The websites AI tools cited most often when answering customer questions about your industry.</p>
4508
+ <div class="client-bar-list">
4509
+ ${ai.map((d) => {
4510
+ const pct = aiMax > 0 ? Math.max(d.count / aiMax * 100, 1.5) : 0;
4511
+ const label = escapeHtml(d.domain) + (d.isCompetitor ? ' <span style="color:' + COLORS.textFaint + ';font-size:11px;">(competitor)</span>' : "");
4512
+ return `<div class="client-bar-row">
4513
+ <span class="bar-label">${label}</span>
4514
+ <div class="bar-track"><div class="bar-fill bar-fill-neutral" style="width:${pct}%"></div></div>
4515
+ <span class="bar-value">${formatNumber(d.count)}\xD7</span>
4516
+ </div>`;
4517
+ }).join("")}
4518
+ </div>
4183
4519
  </div>`);
4184
4520
  }
4185
- if (report.gsc) {
4186
- evidenceCards.push(`<div class="diagnostic-card tone-neutral">
4187
- <h3>Search demand</h3>
4188
- <p>Search Console shows ${formatNumber(report.gsc.totalImpressions)} impressions and ${formatNumber(report.gsc.totalClicks)} clicks in the report window.</p>
4189
- <ul>${report.gsc.topQueries.slice(0, 5).map((q) => `<li>${escapeHtml(q.query)}: ${formatNumber(q.impressions)} impressions</li>`).join("")}</ul>
4521
+ if (indexing) {
4522
+ const tone = indexing.indexedPct >= 90 ? "positive" : indexing.indexedPct >= 70 ? "caution" : "negative";
4523
+ const fillPct = Math.max(indexing.indexedPct, 1.5);
4524
+ cards.push(`<div class="client-card">
4525
+ <h3>Pages Google can find on your site</h3>
4526
+ <p class="card-subtitle">Google indexing your site increases the chances of it appearing in AI search (especially Gemini).</p>
4527
+ <div class="client-progress-number tone-${tone}">${indexing.indexedPct}%</div>
4528
+ <div style="font-size:12px;color:${COLORS.textMuted};">${formatNumber(indexing.indexed)} of ${formatNumber(indexing.total)} pages indexed</div>
4529
+ <div class="client-progress-bar"><div class="client-progress-fill tone-${tone}" style="width:${fillPct}%"></div></div>
4530
+ <p style="margin:0;font-size:12px;color:${COLORS.textMuted};"><strong style="color:${COLORS.text};">${formatNumber(indexing.notIndexed)}</strong> ${pluralize(indexing.notIndexed, "page is", "pages are")} not indexed yet.</p>
4190
4531
  </div>`);
4191
4532
  }
4192
- if (report.indexingHealth) {
4193
- const tone = report.indexingHealth.indexedPct >= 90 ? "positive" : report.indexingHealth.indexedPct >= 70 ? "caution" : "negative";
4194
- evidenceCards.push(`<div class="diagnostic-card tone-${tone}">
4195
- <h3>Indexing readiness</h3>
4196
- <p>${report.indexingHealth.indexedPct}% of inspected URLs are indexed.</p>
4197
- <ul><li>${formatNumber(report.indexingHealth.indexed)} indexed</li><li>${formatNumber(report.indexingHealth.notIndexed)} not indexed</li></ul>
4533
+ if (gsc) {
4534
+ const queries2 = gsc.topQueries.slice(0, 5);
4535
+ const queryRows = queries2.length > 0 ? `<div class="client-bar-list">
4536
+ ${queries2.map((q) => {
4537
+ const pct = gscMax > 0 ? Math.max(q.impressions / gscMax * 100, 1.5) : 0;
4538
+ return `<div class="client-bar-row">
4539
+ <span class="bar-label">${escapeHtml(q.query)}</span>
4540
+ <div class="bar-track"><div class="bar-fill bar-fill-sky" style="width:${pct}%"></div></div>
4541
+ <span class="bar-value">${formatNumber(q.impressions)} ${pluralize(q.impressions, "search", "searches")}</span>
4542
+ </div>`;
4543
+ }).join("")}
4544
+ </div>` : "";
4545
+ cards.push(`<div class="client-card">
4546
+ <h3>What people search Google for</h3>
4547
+ <p class="card-subtitle">You appeared in <strong style="color:${COLORS.text};">${formatNumber(gsc.totalImpressions)}</strong> Google searches and got <strong style="color:${COLORS.text};">${formatNumber(gsc.totalClicks)}</strong> ${pluralize(gsc.totalClicks, "click")} this period.</p>
4548
+ ${queryRows}
4198
4549
  </div>`);
4199
4550
  }
4200
- const opportunities = dedupeReportOpportunities(report);
4201
4551
  if (opportunities.length > 0) {
4202
- evidenceCards.push(`<div class="diagnostic-card tone-caution">
4203
- <h3>Content opportunities</h3>
4204
- <p>Canonry found topics where better content could improve AI citations.</p>
4205
- <ul>${opportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
4552
+ cards.push(`<div class="client-card">
4553
+ <h3>Topics where you could improve</h3>
4554
+ <p class="card-subtitle">Customer questions where better content on your site would help AI cite you.</p>
4555
+ <ul class="client-opportunity-list">
4556
+ ${opportunities.map((o) => `<li>
4557
+ <div class="op-query">${escapeHtml(o.query)}</div>
4558
+ <div class="op-action">${escapeHtml(contentActionLabel(o.action))}</div>
4559
+ </li>`).join("")}
4560
+ </ul>
4206
4561
  </div>`);
4207
4562
  }
4208
4563
  return section(
4209
4564
  {
4210
4565
  id: "client-evidence-summary",
4211
- eyebrow: "Evidence",
4212
- title: "Why This Is The Plan",
4213
- intro: "A concise evidence view for the client summary. The agency report keeps the full matrices and detailed tables."
4566
+ eyebrow: "What we based this on",
4567
+ title: "The signals behind this plan",
4568
+ intro: "The data behind the recommendations above. Switch to Agency for the full breakdowns."
4214
4569
  },
4215
- evidenceCards.length > 0 ? `<div class="diagnostics-grid">${evidenceCards.join("")}</div>` : renderEmpty("No supporting evidence sections are populated yet.")
4570
+ cards.length > 0 ? `<div class="client-evidence-grid">${cards.join("")}</div>` : renderEmpty("No supporting evidence yet \u2014 this fills in after the first AI check.")
4216
4571
  );
4217
4572
  }
4218
4573
  function renderAgencyDiagnostics(report) {
@@ -4242,12 +4597,12 @@ function renderReportHtml(report, opts = {}) {
4242
4597
  const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
4243
4598
  const sections = audience === "client" ? [
4244
4599
  renderClientSummary(report),
4245
- renderWhatsChanged(report),
4600
+ renderWhatsChanged(report, "client"),
4246
4601
  renderAudienceActionPlan(report, "client"),
4247
4602
  renderClientEvidenceSummary(report)
4248
4603
  ].join("\n") : [
4249
4604
  renderExecutiveSummary(report),
4250
- renderWhatsChanged(report),
4605
+ renderWhatsChanged(report, "agency"),
4251
4606
  renderAudienceActionPlan(report, "agency"),
4252
4607
  renderAgencyDiagnostics(report),
4253
4608
  renderCitationScorecard(report),
@@ -4276,12 +4631,12 @@ function renderReportHtml(report, opts = {}) {
4276
4631
  <body>
4277
4632
  <div class="container">
4278
4633
  <header class="header">
4279
- <div class="eyebrow">${audience === "client" ? "AEO Client Summary" : "AEO Agency Report"}</div>
4634
+ <div class="eyebrow">AI Visibility Report</div>
4280
4635
  <h1>${escapeHtml(report.meta.project.displayName)}</h1>
4281
4636
  <div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())}${renderHeaderLocationFragment(report.meta.location)} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
4282
4637
  </header>
4283
4638
  ${sections}
4284
- <footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
4639
+ <footer class="footer">Generated by <a href="https://canonry.ai">canonry</a> \xB7 ${escapeHtml(formatIsoDate(report.meta.generatedAt))}</footer>
4285
4640
  </div>
4286
4641
  <script type="application/json" id="canonry-report-data">${json}</script>
4287
4642
  </body>
@@ -10672,6 +11027,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
10672
11027
  { name: "date" },
10673
11028
  { name: sourceDim },
10674
11029
  { name: mediumDim },
11030
+ { name: "sessionDefaultChannelGroup" },
10675
11031
  { name: "landingPagePlusQueryString" }
10676
11032
  ],
10677
11033
  metrics: [
@@ -10697,7 +11053,8 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
10697
11053
  date: row.dimensionValues[0].value,
10698
11054
  source: row.dimensionValues[1].value,
10699
11055
  medium: row.dimensionValues[2].value,
10700
- landingPage: row.dimensionValues[3]?.value ?? "(not set)",
11056
+ channelGroup: row.dimensionValues[3]?.value ?? "(not set)",
11057
+ landingPage: row.dimensionValues[4]?.value ?? "(not set)",
10701
11058
  sessions: parseInt(row.metricValues[0].value, 10) || 0,
10702
11059
  users: parseInt(row.metricValues[1].value, 10) || 0,
10703
11060
  sourceDimension: dimLabel
@@ -10710,7 +11067,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
10710
11067
  }
10711
11068
  const deduped = /* @__PURE__ */ new Map();
10712
11069
  for (const row of rows) {
10713
- const key = `${row.date}::${row.source}::${row.medium}::${row.sourceDimension}::${row.landingPage}`;
11070
+ const key = `${row.date}::${row.source}::${row.medium}::${row.sourceDimension}::${row.channelGroup}::${row.landingPage}`;
10714
11071
  const existing = deduped.get(key);
10715
11072
  if (!existing) {
10716
11073
  deduped.set(key, row);
@@ -12304,6 +12661,37 @@ function formatSharePct(numerator, total) {
12304
12661
  if (rounded === 0) return "<1%";
12305
12662
  return `${rounded}%`;
12306
12663
  }
12664
+ var SOCIAL_CHANNEL_GROUPS2 = /* @__PURE__ */ new Set(["Organic Social", "Paid Social"]);
12665
+ function buildChannelBreakdown(input) {
12666
+ const aiSessions = [...input.aiSessionsByChannelGroup.values()].reduce((sum, sessions) => sum + sessions, 0);
12667
+ const aiOrganicOverlap = Math.min(input.organicSessions, input.aiSessionsByChannelGroup.get("Organic Search") ?? 0);
12668
+ const aiSocialOverlap = Math.min(
12669
+ input.socialSessions,
12670
+ [...input.aiSessionsByChannelGroup.entries()].filter(([channelGroup]) => SOCIAL_CHANNEL_GROUPS2.has(channelGroup)).reduce((sum, [, sessions]) => sum + sessions, 0)
12671
+ );
12672
+ const aiDirectOverlap = Math.min(input.directSessions, input.aiSessionsByChannelGroup.get("Direct") ?? 0);
12673
+ const organicSessions = Math.max(0, input.organicSessions - aiOrganicOverlap);
12674
+ const socialSessions = Math.max(0, input.socialSessions - aiSocialOverlap);
12675
+ const directSessions = Math.max(0, input.directSessions - aiDirectOverlap);
12676
+ const coveredSessions = organicSessions + socialSessions + directSessions + aiSessions;
12677
+ const otherSessions = Math.max(0, input.totalSessions - coveredSessions);
12678
+ const bucket = (sessions) => ({
12679
+ sessions,
12680
+ sharePct: input.totalSessions > 0 ? Math.round(sessions / input.totalSessions * 100) : 0,
12681
+ sharePctDisplay: formatSharePct(sessions, input.totalSessions)
12682
+ });
12683
+ return {
12684
+ organic: bucket(organicSessions),
12685
+ social: bucket(socialSessions),
12686
+ direct: bucket(directSessions),
12687
+ ai: bucket(aiSessions),
12688
+ other: {
12689
+ sessions: otherSessions,
12690
+ sharePct: input.totalSessions > 0 ? Math.round(otherSessions / input.totalSessions * 100) : 0,
12691
+ sharePctDisplay: input.totalSessions <= 0 && coveredSessions > 0 ? "\u2014" : formatSharePct(otherSessions, input.totalSessions)
12692
+ }
12693
+ };
12694
+ }
12307
12695
  function pickWinningDimension(rows, tupleKey) {
12308
12696
  const winners = /* @__PURE__ */ new Map();
12309
12697
  for (const row of rows) {
@@ -12592,6 +12980,7 @@ async function ga4Routes(app, opts) {
12592
12980
  source: row.source,
12593
12981
  medium: row.medium,
12594
12982
  sourceDimension: row.sourceDimension,
12983
+ channelGroup: row.channelGroup,
12595
12984
  landingPage: row.landingPage,
12596
12985
  landingPageNormalized: normalizeUrlPath(row.landingPage),
12597
12986
  sessions: row.sessions,
@@ -12783,10 +13172,18 @@ async function ga4Routes(app, opts) {
12783
13172
  GROUP BY date, source, medium
12784
13173
  )`
12785
13174
  ).get();
12786
- const aiBySession = app.db.select({
13175
+ const aiBySessionRows = app.db.select({
13176
+ channelGroup: gaAiReferrals.channelGroup,
12787
13177
  sessions: sql5`COALESCE(SUM(${gaAiReferrals.sessions}), 0)`,
12788
13178
  users: sql5`COALESCE(SUM(${gaAiReferrals.users}), 0)`
12789
- }).from(gaAiReferrals).where(and9(...aiConditions, eq21(gaAiReferrals.sourceDimension, "session"))).get();
13179
+ }).from(gaAiReferrals).where(and9(...aiConditions, eq21(gaAiReferrals.sourceDimension, "session"))).groupBy(gaAiReferrals.channelGroup).all();
13180
+ const aiSessionsByChannelGroup = /* @__PURE__ */ new Map();
13181
+ let aiBySessionUsers = 0;
13182
+ for (const row of aiBySessionRows) {
13183
+ aiSessionsByChannelGroup.set(row.channelGroup, row.sessions ?? 0);
13184
+ aiBySessionUsers += row.users ?? 0;
13185
+ }
13186
+ const aiBySessionSessions = [...aiSessionsByChannelGroup.values()].reduce((sum, sessions) => sum + sessions, 0);
12790
13187
  const socialReferrals = app.db.select({
12791
13188
  source: gaSocialReferrals.source,
12792
13189
  medium: gaSocialReferrals.medium,
@@ -12801,9 +13198,18 @@ async function ga4Routes(app, opts) {
12801
13198
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq21(gaTrafficSummaries.projectId, project.id)).orderBy(desc10(gaTrafficSummaries.syncedAt)).limit(1).get();
12802
13199
  const total = summaryRow?.totalSessions ?? 0;
12803
13200
  const totalDirectSessions = directTotalRow?.totalDirectSessions ?? 0;
13201
+ const totalOrganicSessions = summaryRow?.totalOrganicSessions ?? 0;
13202
+ const socialSessions = socialTotals?.sessions ?? 0;
13203
+ const channelBreakdown = buildChannelBreakdown({
13204
+ totalSessions: total,
13205
+ organicSessions: totalOrganicSessions,
13206
+ socialSessions,
13207
+ directSessions: totalDirectSessions,
13208
+ aiSessionsByChannelGroup
13209
+ });
12804
13210
  return {
12805
13211
  totalSessions: total,
12806
- totalOrganicSessions: summaryRow?.totalOrganicSessions ?? 0,
13212
+ totalOrganicSessions,
12807
13213
  totalDirectSessions,
12808
13214
  totalUsers: summaryRow?.totalUsers ?? 0,
12809
13215
  topPages: rows.map((r) => ({
@@ -12830,8 +13236,8 @@ async function ga4Routes(app, opts) {
12830
13236
  })),
12831
13237
  aiSessionsDeduped: aiDeduped?.sessions ?? 0,
12832
13238
  aiUsersDeduped: aiDeduped?.users ?? 0,
12833
- aiSessionsBySession: aiBySession?.sessions ?? 0,
12834
- aiUsersBySession: aiBySession?.users ?? 0,
13239
+ aiSessionsBySession: aiBySessionSessions,
13240
+ aiUsersBySession: aiBySessionUsers,
12835
13241
  socialReferrals: socialReferrals.map((r) => ({
12836
13242
  source: r.source,
12837
13243
  medium: r.medium,
@@ -12839,18 +13245,22 @@ async function ga4Routes(app, opts) {
12839
13245
  sessions: r.sessions ?? 0,
12840
13246
  users: r.users ?? 0
12841
13247
  })),
12842
- socialSessions: socialTotals?.sessions ?? 0,
13248
+ socialSessions,
12843
13249
  socialUsers: socialTotals?.users ?? 0,
12844
- organicSharePct: total > 0 ? Math.round((summaryRow?.totalOrganicSessions ?? 0) / total * 100) : 0,
13250
+ channelBreakdown,
13251
+ organicSharePct: total > 0 ? Math.round(totalOrganicSessions / total * 100) : 0,
12845
13252
  aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
12846
- aiSharePctBySession: total > 0 ? Math.round((aiBySession?.sessions ?? 0) / total * 100) : 0,
13253
+ aiSharePctBySession: total > 0 ? Math.round(aiBySessionSessions / total * 100) : 0,
12847
13254
  directSharePct: total > 0 ? Math.round(totalDirectSessions / total * 100) : 0,
12848
- socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
12849
- organicSharePctDisplay: formatSharePct(summaryRow?.totalOrganicSessions ?? 0, total),
13255
+ socialSharePct: total > 0 ? Math.round(socialSessions / total * 100) : 0,
13256
+ otherSessions: channelBreakdown.other.sessions,
13257
+ otherSharePct: channelBreakdown.other.sharePct,
13258
+ otherSharePctDisplay: channelBreakdown.other.sharePctDisplay,
13259
+ organicSharePctDisplay: formatSharePct(totalOrganicSessions, total),
12850
13260
  aiSharePctDisplay: formatSharePct(aiDeduped?.sessions ?? 0, total),
12851
- aiSharePctBySessionDisplay: formatSharePct(aiBySession?.sessions ?? 0, total),
13261
+ aiSharePctBySessionDisplay: formatSharePct(aiBySessionSessions, total),
12852
13262
  directSharePctDisplay: formatSharePct(totalDirectSessions, total),
12853
- socialSharePctDisplay: formatSharePct(socialTotals?.sessions ?? 0, total),
13263
+ socialSharePctDisplay: formatSharePct(socialSessions, total),
12854
13264
  lastSyncedAt: latestSync?.syncedAt ?? null,
12855
13265
  periodStart: (() => {
12856
13266
  const start = cutoffDate ?? summaryMeta?.periodStart ?? null;