@ainyc/canonry 4.2.2 → 4.8.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.
@@ -4,7 +4,7 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-SR7TGHHG.js";
7
+ } from "./chunk-5KIFQH52.js";
8
8
  import {
9
9
  DEFAULT_RUN_HISTORY_LIMIT,
10
10
  IntelligenceService,
@@ -61,7 +61,7 @@ import {
61
61
  runs,
62
62
  schedules,
63
63
  usageCounters
64
- } from "./chunk-7YSI4GFA.js";
64
+ } from "./chunk-QEPFB7UW.js";
65
65
  import {
66
66
  AGENT_MEMORY_VALUE_MAX_BYTES,
67
67
  AGENT_PROVIDER_IDS,
@@ -77,6 +77,7 @@ import {
77
77
  RunStatuses,
78
78
  RunTriggers,
79
79
  absolutizeProjectUrl,
80
+ actionConfidenceLabel,
80
81
  agentBusy,
81
82
  agentMemoryDeleteRequestSchema,
82
83
  agentMemoryUpsertRequestSchema,
@@ -89,12 +90,14 @@ import {
89
90
  categoryLabel,
90
91
  citationStateToCited,
91
92
  competitorBatchRequestSchema,
93
+ contentActionLabel,
92
94
  deliveryFailed,
93
95
  determineAnswerMentioned,
94
96
  effectiveDomains,
95
97
  emptyCitationVisibility,
96
98
  extractAnswerMentions,
97
99
  findDuplicateLocationLabels,
100
+ getProviderLocationHandling,
98
101
  hasLocationLabel,
99
102
  internalError,
100
103
  isAgentProviderId,
@@ -113,6 +116,11 @@ import {
113
116
  providerError,
114
117
  queryGenerateRequestSchema,
115
118
  registrableDomain,
119
+ reportActionCategoryLabel,
120
+ reportActionTone,
121
+ reportConfidenceLabel,
122
+ reportHorizonLabel,
123
+ reportSeverityLabel,
116
124
  resolveConfigSpecQueries,
117
125
  resolveSnapshotRequestQueries,
118
126
  runInProgress,
@@ -127,7 +135,7 @@ import {
127
135
  visibilityStateFromAnswerMentioned,
128
136
  windowCutoff,
129
137
  wordpressEnvSchema
130
- } from "./chunk-T2I6AO7D.js";
138
+ } from "./chunk-IJEP6LB4.js";
131
139
 
132
140
  // src/telemetry.ts
133
141
  import crypto from "crypto";
@@ -2620,12 +2628,43 @@ function formatLandingPageHtml(raw) {
2620
2628
  function formatDate(iso) {
2621
2629
  if (!iso) return "\u2014";
2622
2630
  try {
2623
- const d = new Date(iso);
2624
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2631
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
2632
+ const options = { month: "short", day: "numeric", year: "numeric" };
2633
+ 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);
2634
+ if (Number.isNaN(d.getTime())) return iso;
2635
+ return d.toLocaleDateString("en-US", dateOnly ? { ...options, timeZone: "UTC" } : options);
2625
2636
  } catch {
2626
2637
  return iso;
2627
2638
  }
2628
2639
  }
2640
+ function formatDateRange(start, end) {
2641
+ if (!start && !end) return "";
2642
+ if (start && end) return `${formatDate(start)} \u2192 ${formatDate(end)}`;
2643
+ return formatDate(start || end);
2644
+ }
2645
+ function gscDateRange(report) {
2646
+ const summary = report.executiveSummary.gsc;
2647
+ const gsc = report.gsc;
2648
+ const start = summary?.periodStart || gsc?.periodStart || gsc?.trend[0]?.date || "";
2649
+ const end = summary?.periodEnd || gsc?.periodEnd || gsc?.trend.at(-1)?.date || "";
2650
+ return formatDateRange(start, end);
2651
+ }
2652
+ function pluralize(count, singular, plural = `${singular}s`) {
2653
+ return count === 1 ? singular : plural;
2654
+ }
2655
+ function compactInlineList(items, limit = 3) {
2656
+ const visible = items.slice(0, limit);
2657
+ const more = items.length - visible.length;
2658
+ return `${visible.join(", ")}${more > 0 ? `, +${more} more` : ""}`;
2659
+ }
2660
+ function renderProofChips(items, limit = 3) {
2661
+ if (items.length === 0) return "";
2662
+ const visible = items.slice(0, limit);
2663
+ const more = items.length - visible.length;
2664
+ const chips = visible.map((item) => `<span class="proof-chip">${escapeHtml(item)}</span>`);
2665
+ if (more > 0) chips.push(`<span class="proof-chip">+${more} more</span>`);
2666
+ return `<div class="proof-chips">${chips.join("")}</div>`;
2667
+ }
2629
2668
  function pressureTone(label) {
2630
2669
  if (label === "High") return "negative";
2631
2670
  if (label === "Moderate") return "caution";
@@ -2672,7 +2711,7 @@ body {
2672
2711
  font-size: 32px;
2673
2712
  font-weight: 700;
2674
2713
  margin: 0 0 8px;
2675
- letter-spacing: -0.02em;
2714
+ letter-spacing: 0;
2676
2715
  }
2677
2716
  .header .subtitle {
2678
2717
  color: ${COLORS.textMuted};
@@ -2680,7 +2719,7 @@ body {
2680
2719
  }
2681
2720
  .eyebrow {
2682
2721
  text-transform: uppercase;
2683
- letter-spacing: 0.08em;
2722
+ letter-spacing: 0;
2684
2723
  font-size: 10px;
2685
2724
  color: ${COLORS.textFaint};
2686
2725
  font-weight: 600;
@@ -2693,11 +2732,75 @@ section.report-section h2 {
2693
2732
  font-size: 22px;
2694
2733
  font-weight: 700;
2695
2734
  margin: 0 0 24px;
2696
- letter-spacing: -0.01em;
2735
+ letter-spacing: 0;
2697
2736
  }
2698
2737
  section.report-section .section-intro {
2699
2738
  color: ${COLORS.textMuted};
2700
2739
  margin-bottom: 24px;
2740
+ max-width: 760px;
2741
+ }
2742
+ .executive-hero {
2743
+ display: grid;
2744
+ grid-template-columns: minmax(0, 1.35fr) minmax(240px, 0.65fr);
2745
+ gap: 16px;
2746
+ margin-bottom: 16px;
2747
+ }
2748
+ .headline-card {
2749
+ background: #111827;
2750
+ border: 1px solid ${COLORS.border};
2751
+ border-radius: 8px;
2752
+ padding: 28px;
2753
+ min-height: 220px;
2754
+ display: flex;
2755
+ flex-direction: column;
2756
+ justify-content: space-between;
2757
+ }
2758
+ .headline-card .hero-kicker {
2759
+ color: ${COLORS.textMuted};
2760
+ font-size: 12px;
2761
+ font-weight: 600;
2762
+ text-transform: uppercase;
2763
+ letter-spacing: 0;
2764
+ }
2765
+ .headline-card .hero-title {
2766
+ font-size: 44px;
2767
+ line-height: 1.05;
2768
+ font-weight: 800;
2769
+ letter-spacing: 0;
2770
+ margin: 18px 0;
2771
+ }
2772
+ .headline-card .hero-subtitle {
2773
+ color: ${COLORS.textMuted};
2774
+ font-size: 15px;
2775
+ max-width: 620px;
2776
+ }
2777
+ .hero-proof-grid {
2778
+ display: grid;
2779
+ gap: 12px;
2780
+ }
2781
+ .hero-proof {
2782
+ background: ${COLORS.surface};
2783
+ border: 1px solid ${COLORS.border};
2784
+ border-radius: 8px;
2785
+ padding: 18px;
2786
+ }
2787
+ .hero-proof .mini-label {
2788
+ color: ${COLORS.textFaint};
2789
+ font-size: 10px;
2790
+ font-weight: 600;
2791
+ text-transform: uppercase;
2792
+ letter-spacing: 0;
2793
+ margin-bottom: 8px;
2794
+ }
2795
+ .hero-proof .mini-value {
2796
+ font-size: 30px;
2797
+ line-height: 1;
2798
+ font-weight: 800;
2799
+ }
2800
+ .hero-proof .mini-copy {
2801
+ color: ${COLORS.textMuted};
2802
+ font-size: 12px;
2803
+ margin-top: 8px;
2701
2804
  }
2702
2805
  .metric-grid {
2703
2806
  display: grid;
@@ -2712,7 +2815,7 @@ section.report-section .section-intro {
2712
2815
  }
2713
2816
  .metric .label {
2714
2817
  text-transform: uppercase;
2715
- letter-spacing: 0.08em;
2818
+ letter-spacing: 0;
2716
2819
  font-size: 10px;
2717
2820
  color: ${COLORS.textFaint};
2718
2821
  font-weight: 600;
@@ -2721,7 +2824,7 @@ section.report-section .section-intro {
2721
2824
  .metric .value {
2722
2825
  font-size: 28px;
2723
2826
  font-weight: 700;
2724
- letter-spacing: -0.02em;
2827
+ letter-spacing: 0;
2725
2828
  }
2726
2829
  .metric .delta {
2727
2830
  font-size: 12px;
@@ -2746,6 +2849,57 @@ section.report-section .section-intro {
2746
2849
  .finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
2747
2850
  .finding strong { display: block; margin-bottom: 4px; }
2748
2851
  .finding span { color: ${COLORS.textMuted}; font-size: 13px; }
2852
+ .market-scope-card { margin-top: 16px; }
2853
+ .market-scope-grid {
2854
+ display: grid;
2855
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2856
+ gap: 12px;
2857
+ }
2858
+ .scope-tile {
2859
+ background: #09090b;
2860
+ border: 1px solid ${COLORS.border};
2861
+ border-radius: 8px;
2862
+ padding: 14px;
2863
+ }
2864
+ .scope-tile .scope-label {
2865
+ color: ${COLORS.textFaint};
2866
+ font-size: 10px;
2867
+ font-weight: 600;
2868
+ text-transform: uppercase;
2869
+ letter-spacing: 0;
2870
+ margin-bottom: 8px;
2871
+ }
2872
+ .scope-tile .scope-value {
2873
+ font-size: 18px;
2874
+ line-height: 1.2;
2875
+ font-weight: 700;
2876
+ }
2877
+ .scope-tile .scope-copy {
2878
+ color: ${COLORS.textMuted};
2879
+ font-size: 12px;
2880
+ margin-top: 8px;
2881
+ }
2882
+ .scope-warning {
2883
+ margin-top: 12px;
2884
+ border: 1px solid ${COLORS.caution}55;
2885
+ background: ${COLORS.caution}14;
2886
+ border-radius: 8px;
2887
+ padding: 12px 14px;
2888
+ color: ${COLORS.textMuted};
2889
+ font-size: 13px;
2890
+ }
2891
+ .scope-warning strong { color: ${COLORS.text}; display: block; margin-bottom: 4px; }
2892
+ .source-origin-headline { margin: 0 0 12px; font-size: 14px; color: ${COLORS.text}; }
2893
+ .source-origin-headline strong { color: ${COLORS.text}; }
2894
+ .source-bars { display: flex; flex-direction: column; gap: 6px; }
2895
+ .source-bar-row { display: grid; grid-template-columns: 220px 1fr 90px; align-items: center; gap: 12px; font-size: 13px; }
2896
+ .source-bar-label { color: ${COLORS.textMuted}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2897
+ .source-bar-track { height: 14px; background: ${COLORS.border}; border-radius: 3px; overflow: hidden; }
2898
+ .source-bar-fill { height: 100%; border-radius: 3px; }
2899
+ .source-bar-value { color: ${COLORS.text}; text-align: right; font-variant-numeric: tabular-nums; }
2900
+ .source-bar-pct { color: ${COLORS.textFaint}; font-size: 11px; }
2901
+ .driver-list { margin: 0; padding-left: 16px; font-size: 12px; color: ${COLORS.textMuted}; }
2902
+ .driver-list li { margin: 2px 0; }
2749
2903
  table.report-table {
2750
2904
  width: 100%;
2751
2905
  border-collapse: collapse;
@@ -2756,18 +2910,24 @@ table.report-table th, table.report-table td {
2756
2910
  padding: 10px 12px;
2757
2911
  border-bottom: 1px solid ${COLORS.border};
2758
2912
  vertical-align: top;
2759
- overflow-wrap: anywhere;
2760
- word-break: break-word;
2913
+ overflow-wrap: break-word;
2914
+ hyphens: auto;
2761
2915
  }
2762
2916
  table.report-table th {
2763
2917
  font-weight: 600;
2764
2918
  color: ${COLORS.textMuted};
2765
2919
  text-transform: uppercase;
2766
- letter-spacing: 0.06em;
2920
+ letter-spacing: 0;
2767
2921
  font-size: 10px;
2768
2922
  }
2769
2923
  table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
2770
2924
  table.report-table td.page-cell { max-width: 0; }
2925
+ table.insights-table { table-layout: fixed; }
2926
+ table.insights-table th.col-severity, table.insights-table td.col-severity { width: 96px; }
2927
+ table.insights-table th.col-query, table.insights-table td.col-query { width: 18%; }
2928
+ table.insights-table th.col-provider, table.insights-table td.col-provider { width: 88px; }
2929
+ table.insights-table th.col-title, table.insights-table td.col-title { width: 28%; }
2930
+ table.insights-table th.col-recommendation, table.insights-table td.col-recommendation { width: auto; }
2771
2931
  table.report-table td.page-cell .page-path {
2772
2932
  display: block;
2773
2933
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
@@ -2860,12 +3020,159 @@ table.report-table td .badge {
2860
3020
  .step .horizon {
2861
3021
  text-transform: uppercase;
2862
3022
  font-size: 10px;
2863
- letter-spacing: 0.08em;
3023
+ letter-spacing: 0;
2864
3024
  color: ${COLORS.textFaint};
2865
3025
  font-weight: 600;
2866
3026
  }
2867
3027
  .step .title { font-weight: 600; }
2868
3028
  .step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
3029
+ .action-card-grid {
3030
+ display: grid;
3031
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
3032
+ gap: 16px;
3033
+ }
3034
+ .action-card {
3035
+ background: ${COLORS.surface};
3036
+ border: 1px solid ${COLORS.border};
3037
+ border-radius: 8px;
3038
+ padding: 18px;
3039
+ display: flex;
3040
+ flex-direction: column;
3041
+ gap: 12px;
3042
+ }
3043
+ .action-card .action-head {
3044
+ display: grid;
3045
+ grid-template-columns: 42px 1fr;
3046
+ gap: 12px;
3047
+ align-items: start;
3048
+ }
3049
+ .action-card .action-rank {
3050
+ border: 1px solid ${COLORS.border};
3051
+ border-radius: 8px;
3052
+ height: 42px;
3053
+ display: flex;
3054
+ align-items: center;
3055
+ justify-content: center;
3056
+ font-size: 16px;
3057
+ font-weight: 800;
3058
+ color: ${COLORS.text};
3059
+ background: #09090b;
3060
+ }
3061
+ .action-card .action-meta {
3062
+ display: flex;
3063
+ flex-wrap: wrap;
3064
+ gap: 8px;
3065
+ }
3066
+ .action-card h3 {
3067
+ font-size: 16px;
3068
+ margin: 8px 0 0;
3069
+ }
3070
+ .action-card p {
3071
+ margin: 0;
3072
+ color: ${COLORS.textMuted};
3073
+ }
3074
+ .action-card ul {
3075
+ margin: 0 0 12px;
3076
+ padding-left: 18px;
3077
+ color: ${COLORS.textMuted};
3078
+ font-size: 13px;
3079
+ }
3080
+ .action-card li { margin: 4px 0; }
3081
+ .proof-chips {
3082
+ display: flex;
3083
+ flex-wrap: wrap;
3084
+ gap: 8px;
3085
+ }
3086
+ .proof-chip {
3087
+ border: 1px solid ${COLORS.border};
3088
+ border-radius: 8px;
3089
+ padding: 6px 8px;
3090
+ color: ${COLORS.textMuted};
3091
+ font-size: 12px;
3092
+ background: #09090b;
3093
+ }
3094
+ .action-details {
3095
+ color: ${COLORS.textMuted};
3096
+ font-size: 12px;
3097
+ }
3098
+ .action-details summary {
3099
+ cursor: pointer;
3100
+ color: ${COLORS.text};
3101
+ font-weight: 600;
3102
+ }
3103
+ .action-card .success-metric {
3104
+ color: ${COLORS.text};
3105
+ font-size: 13px;
3106
+ border-top: 1px solid ${COLORS.border};
3107
+ padding-top: 10px;
3108
+ margin-top: 12px;
3109
+ }
3110
+ .client-notes {
3111
+ margin-top: 18px;
3112
+ display: grid;
3113
+ gap: 8px;
3114
+ }
3115
+ .client-note {
3116
+ color: ${COLORS.textMuted};
3117
+ font-size: 13px;
3118
+ background: ${COLORS.surface};
3119
+ border: 1px solid ${COLORS.border};
3120
+ border-radius: 8px;
3121
+ padding: 10px 12px;
3122
+ }
3123
+ .diagnostics-grid {
3124
+ display: grid;
3125
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
3126
+ gap: 12px;
3127
+ }
3128
+ .diagnostic-card {
3129
+ background: ${COLORS.surface};
3130
+ border: 1px solid ${COLORS.border};
3131
+ border-left-width: 3px;
3132
+ border-radius: 8px;
3133
+ padding: 14px 16px;
3134
+ }
3135
+ .diagnostic-card h3 { font-size: 14px; margin: 0 0 6px; }
3136
+ .diagnostic-card p { margin: 0 0 8px; color: ${COLORS.textMuted}; font-size: 13px; }
3137
+ .diagnostic-card ul { margin: 0; padding-left: 16px; color: ${COLORS.textMuted}; font-size: 12px; }
3138
+ .diagnostic-card .proof-chips { margin-top: 10px; }
3139
+ .diagnostic-card.tone-positive { border-left-color: ${COLORS.positive}; }
3140
+ .diagnostic-card.tone-caution { border-left-color: ${COLORS.caution}; }
3141
+ .diagnostic-card.tone-negative { border-left-color: ${COLORS.negative}; }
3142
+ .diagnostic-card.tone-neutral { border-left-color: ${COLORS.neutral}; }
3143
+ .opportunity-grid {
3144
+ display: grid;
3145
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
3146
+ gap: 12px;
3147
+ margin-bottom: 16px;
3148
+ }
3149
+ .opportunity-card {
3150
+ background: ${COLORS.surface};
3151
+ border: 1px solid ${COLORS.border};
3152
+ border-radius: 8px;
3153
+ padding: 16px;
3154
+ }
3155
+ .opportunity-card .opportunity-score {
3156
+ font-size: 32px;
3157
+ line-height: 1;
3158
+ font-weight: 800;
3159
+ margin-bottom: 10px;
3160
+ }
3161
+ .opportunity-card .opportunity-score-suffix {
3162
+ font-size: 14px;
3163
+ font-weight: 600;
3164
+ color: ${COLORS.textFaint};
3165
+ margin-left: 4px;
3166
+ }
3167
+ .opportunity-card h3 {
3168
+ font-size: 14px;
3169
+ margin: 0 0 8px;
3170
+ }
3171
+ .opportunity-card p {
3172
+ color: ${COLORS.textMuted};
3173
+ font-size: 12px;
3174
+ margin: 0;
3175
+ }
2869
3176
  .footer {
2870
3177
  margin-top: 96px;
2871
3178
  padding-top: 24px;
@@ -2874,6 +3181,14 @@ table.report-table td .badge {
2874
3181
  color: ${COLORS.textFaint};
2875
3182
  font-size: 12px;
2876
3183
  }
3184
+ @media (max-width: 760px) {
3185
+ .container { padding: 32px 16px 72px; }
3186
+ .executive-hero { grid-template-columns: 1fr; }
3187
+ .headline-card .hero-title { font-size: 34px; }
3188
+ .source-bar-row { grid-template-columns: 1fr; gap: 6px; }
3189
+ .source-bar-value { text-align: left; }
3190
+ .chart-grid { grid-template-columns: 1fr; }
3191
+ }
2877
3192
  @media print {
2878
3193
  body { background: white; color: black; }
2879
3194
  section.report-section { break-inside: avoid; }
@@ -2890,15 +3205,163 @@ function section(opts, body) {
2890
3205
  function renderEmpty(message) {
2891
3206
  return `<div class="empty-state">${escapeHtml(message)}</div>`;
2892
3207
  }
3208
+ function locationDisplay(location) {
3209
+ if (!location) return "";
3210
+ const place = [location.city, location.region, location.country].filter(Boolean).join(", ");
3211
+ return place ? `${location.label} (${place})` : location.label;
3212
+ }
3213
+ function renderHeaderLocationFragment(location) {
3214
+ if (!location) return " \xB7 No market set";
3215
+ return ` \xB7 Market: ${escapeHtml(locationDisplay(location))}`;
3216
+ }
3217
+ var REPORT_INTENT_STOPWORDS = /* @__PURE__ */ new Set([
3218
+ "a",
3219
+ "an",
3220
+ "and",
3221
+ "for",
3222
+ "from",
3223
+ "in",
3224
+ "near",
3225
+ "of",
3226
+ "on",
3227
+ "or",
3228
+ "the",
3229
+ "to"
3230
+ ]);
3231
+ function reportIntentModifiers(report) {
3232
+ const location = report.meta.location;
3233
+ if (!location) return /* @__PURE__ */ new Set();
3234
+ return new Set(
3235
+ [location.label, location.city, location.region, location.country].flatMap(tokenizeReportIntent).map(normalizeReportIntentToken).filter(Boolean)
3236
+ );
3237
+ }
3238
+ function dedupeReportActions(report, actions) {
3239
+ const modifiers = reportIntentModifiers(report);
3240
+ if (actions.length <= 1 || modifiers.size === 0) return [...actions];
3241
+ const seen = /* @__PURE__ */ new Set();
3242
+ const result = [];
3243
+ for (const action of actions) {
3244
+ if (action.category !== "content") {
3245
+ result.push(action);
3246
+ continue;
3247
+ }
3248
+ const key = reportIntentKey(extractActionQuery(action), modifiers);
3249
+ if (!key || seen.has(key)) continue;
3250
+ seen.add(key);
3251
+ result.push(action);
3252
+ }
3253
+ return result;
3254
+ }
3255
+ function dedupeReportOpportunities(report) {
3256
+ const modifiers = reportIntentModifiers(report);
3257
+ const opportunities = report.contentOpportunities;
3258
+ if (opportunities.length <= 1 || modifiers.size === 0) return opportunities;
3259
+ const seen = /* @__PURE__ */ new Set();
3260
+ return opportunities.filter((opportunity) => {
3261
+ const key = reportIntentKey(opportunity.query, modifiers);
3262
+ if (!key || seen.has(key)) return false;
3263
+ seen.add(key);
3264
+ return true;
3265
+ });
3266
+ }
3267
+ function extractActionQuery(action) {
3268
+ return action.title.match(/"([^"]+)"/)?.[1] ?? action.successMetric.match(/"([^"]+)"/)?.[1] ?? action.title;
3269
+ }
3270
+ function reportIntentKey(value, modifiers) {
3271
+ const tokens = tokenizeReportIntent(value).map(normalizeReportIntentToken).filter(Boolean).filter((token) => !REPORT_INTENT_STOPWORDS.has(token)).filter((token) => !modifiers.has(token));
3272
+ return [...new Set(tokens)].sort().join(" ");
3273
+ }
3274
+ function tokenizeReportIntent(value) {
3275
+ return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
3276
+ }
3277
+ function normalizeReportIntentToken(token) {
3278
+ if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
3279
+ if (token.length > 4 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
3280
+ return token;
3281
+ }
3282
+ function renderLocationCard(report) {
3283
+ const location = report.meta.location;
3284
+ const handling = report.meta.providerLocationHandling;
3285
+ if (!location && handling.length === 0) return "";
3286
+ const otherLocations = location?.otherConfiguredLabels ?? [];
3287
+ const weakLocationProviders = handling.filter((h) => h.treatment === "ignored" || h.treatment === "browser-geo").map((h) => h.provider);
3288
+ const marketValue = location ? locationDisplay(location) : "No market set";
3289
+ const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : "None";
3290
+ const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching sweep before cross-market recommendations.` : "Single-market report; findings can be read as the current market view." : "No geographic hint was attached to this sweep; read findings as default-market or national results.";
3291
+ const providerCopy = handling.length > 0 ? weakLocationProviders.length > 0 ? `${weakLocationProviders.length} ${pluralize(weakLocationProviders.length, "provider")} need a closer location check.` : `${handling.length} ${pluralize(handling.length, "provider")} received the market context.` : "No provider-level location metadata is available for this report.";
3292
+ const warning = weakLocationProviders.length > 0 ? `<div class="scope-warning">
3293
+ <strong>Location handling needs review</strong>
3294
+ ${escapeHtml(compactInlineList(weakLocationProviders, 4))} used weak or indirect market handling. Treat provider-level differences cautiously.
3295
+ </div>` : "";
3296
+ return `<div class="chart-card market-scope-card">
3297
+ <h3>Market Scope</h3>
3298
+ <div class="market-scope-grid">
3299
+ <div class="scope-tile">
3300
+ <div class="scope-label">Current sweep</div>
3301
+ <div class="scope-value">${escapeHtml(marketValue)}</div>
3302
+ <div class="scope-copy">All findings below are scoped to this run.</div>
3303
+ </div>
3304
+ <div class="scope-tile">
3305
+ <div class="scope-label">Not included</div>
3306
+ <div class="scope-value">${escapeHtml(notIncluded)}</div>
3307
+ <div class="scope-copy">${escapeHtml(interpretation)}</div>
3308
+ </div>
3309
+ <div class="scope-tile">
3310
+ <div class="scope-label">Provider context</div>
3311
+ <div class="scope-value">${handling.length > 0 ? formatNumber(handling.length) : "\u2014"}</div>
3312
+ <div class="scope-copy">${escapeHtml(providerCopy)}</div>
3313
+ </div>
3314
+ </div>
3315
+ ${warning}
3316
+ </div>`;
3317
+ }
2893
3318
  function renderExecutiveSummary(report) {
2894
3319
  const s = report.executiveSummary;
2895
3320
  const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
2896
3321
  const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
3322
+ const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
3323
+ const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
3324
+ const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
3325
+ const headlineTitle = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
3326
+ const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a visibility sweep to populate the first citation and mention baseline.";
3327
+ const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
3328
+ const actionCount = dedupeReportActions(report, priorityActions).length;
3329
+ const heroHtml = `<div class="executive-hero">
3330
+ <div class="headline-card">
3331
+ <div>
3332
+ <div class="hero-kicker">Latest AI visibility sweep</div>
3333
+ <div class="hero-title">${escapeHtml(headlineTitle)}</div>
3334
+ </div>
3335
+ <div class="hero-subtitle">${escapeHtml(headlineSubtitle)}</div>
3336
+ </div>
3337
+ <div class="hero-proof-grid">
3338
+ <div class="hero-proof">
3339
+ <div class="mini-label">Citation trend</div>
3340
+ <div class="mini-value tone-${trendTone}">${escapeHtml(trendLabel)}</div>
3341
+ <div class="mini-copy">${escapeHtml(citedFragment)}</div>
3342
+ </div>
3343
+ <div class="hero-proof">
3344
+ <div class="mini-label">Mention coverage</div>
3345
+ <div class="mini-value">${s.mentionRate}%</div>
3346
+ <div class="mini-copy">${escapeHtml(mentionedFragment)}</div>
3347
+ </div>
3348
+ <div class="hero-proof">
3349
+ <div class="mini-label">Prioritized actions</div>
3350
+ <div class="mini-value">${formatNumber(actionCount)}</div>
3351
+ <div class="mini-copy">Sorted for agency follow-up.</div>
3352
+ </div>
3353
+ </div>
3354
+ </div>`;
2897
3355
  const metrics = [
2898
3356
  {
2899
3357
  label: "Citation rate",
2900
3358
  value: `${s.citationRate}%`,
2901
- delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
3359
+ delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${citedFragment} \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
3360
+ },
3361
+ {
3362
+ label: "Mention rate",
3363
+ value: `${s.mentionRate}%`,
3364
+ delta: mentionedFragment
2902
3365
  },
2903
3366
  {
2904
3367
  label: "Queries tracked",
@@ -2907,10 +3370,11 @@ function renderExecutiveSummary(report) {
2907
3370
  }
2908
3371
  ];
2909
3372
  if (s.gsc) {
3373
+ const dateRange = gscDateRange(report);
2910
3374
  metrics.push({
2911
3375
  label: "GSC clicks",
2912
3376
  value: formatNumber(s.gsc.clicks),
2913
- delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
3377
+ delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR${dateRange ? ` \xB7 ${escapeHtml(dateRange)}` : ""}`
2914
3378
  });
2915
3379
  }
2916
3380
  if (s.ga) {
@@ -2932,14 +3396,15 @@ function renderExecutiveSummary(report) {
2932
3396
  <strong>${escapeHtml(f.title)}</strong>
2933
3397
  <span>${escapeHtml(f.detail)}</span>
2934
3398
  </div>`).join("")}</div>` : "";
3399
+ const locationHtml = renderLocationCard(report);
2935
3400
  return section(
2936
3401
  {
2937
3402
  id: "executive-summary",
2938
3403
  eyebrow: "Section 1",
2939
3404
  title: "Executive Summary",
2940
- intro: "Top-line citation rate with trend versus the prior run, plus the most actionable findings from the latest visibility sweep."
3405
+ intro: "Citation = source list. Mention = answer text. They are independent signals."
2941
3406
  },
2942
- metricsHtml + findingsHtml
3407
+ heroHtml + metricsHtml + findingsHtml + locationHtml
2943
3408
  );
2944
3409
  }
2945
3410
  function renderProviderBars(rates) {
@@ -2977,16 +3442,16 @@ function renderCitationMatrix(scorecard) {
2977
3442
  const cells = scorecard.providers.map((_, pi) => {
2978
3443
  const cell = scorecard.matrix[qi]?.[pi];
2979
3444
  if (!cell) {
2980
- return '<td><span class="cell-pending">\u2014</span></td>';
2981
- }
2982
- if (cell.citationState === CitationStates.cited) {
2983
- return '<td><span class="cell-cited">Cited</span></td>';
3445
+ return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
2984
3446
  }
2985
- return '<td><span class="cell-not-cited">Not cited</span></td>';
3447
+ const citedGlyph = cell.citationState === CitationStates.cited ? '<span class="cell-cited">C</span>' : '<span class="cell-not-cited">c</span>';
3448
+ const mentionedGlyph = cell.answerMentioned === true ? '<span class="cell-cited">M</span>' : cell.answerMentioned === false ? '<span class="cell-not-cited">m</span>' : '<span class="cell-pending">\u2013</span>';
3449
+ return `<td>${citedGlyph} ${mentionedGlyph}</td>`;
2986
3450
  }).join("");
2987
3451
  return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
2988
3452
  }).join("");
2989
- return `<table class="report-table">
3453
+ const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">Legend: <span class="cell-cited">C</span>/<span class="cell-not-cited">c</span> = cited/not, <span class="cell-cited">M</span>/<span class="cell-not-cited">m</span> = mentioned/not, <span class="cell-pending">\u2013</span> = no data.</p>';
3454
+ return `${legend}<table class="report-table">
2990
3455
  <thead><tr><th>Query</th>${headers}</tr></thead>
2991
3456
  <tbody>${rows}</tbody>
2992
3457
  </table>`;
@@ -2997,7 +3462,7 @@ function renderCitationScorecard(report) {
2997
3462
  ${renderCitationMatrix(report.citationScorecard)}
2998
3463
  `;
2999
3464
  return section(
3000
- { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Whether your domain appeared in each AI engine\u2019s source list for every tracked query in the latest sweep \u2014 a cell turns green when your domain was cited, red when it was not, and gray when no snapshot exists for that pair." },
3465
+ { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Provider-by-provider citation and mention coverage for the latest sweep." },
3001
3466
  body
3002
3467
  );
3003
3468
  }
@@ -3079,49 +3544,65 @@ function renderCompetitorLandscape(report) {
3079
3544
  id: "competitor-landscape",
3080
3545
  eyebrow: "Section 3",
3081
3546
  title: "Competitor Landscape",
3082
- intro: "Where tracked competitors appear in AI answers compared to your domain \u2014 both in source citations and in the answer text itself."
3547
+ intro: "Who AI engines cite and mention instead of the client."
3083
3548
  },
3084
3549
  `${charts}${table}`
3085
3550
  );
3086
3551
  }
3087
- function renderDonut(buckets) {
3552
+ var SOURCE_CATEGORY_TONE = {
3553
+ competitor: "negative",
3554
+ directory: "caution",
3555
+ forum: "caution",
3556
+ news: "neutral",
3557
+ reference: "neutral",
3558
+ blog: "neutral",
3559
+ social: "neutral",
3560
+ video: "neutral",
3561
+ ecommerce: "neutral",
3562
+ academic: "neutral",
3563
+ other: "neutral"
3564
+ };
3565
+ function renderCategoryBars(buckets) {
3088
3566
  if (buckets.length === 0) return "";
3089
3567
  const total = buckets.reduce((s, b) => s + b.count, 0);
3090
3568
  if (total === 0) return "";
3091
- const cx = 110;
3092
- const cy = 110;
3093
- const r = 80;
3094
- const innerR = 48;
3095
- let cumulative = 0;
3096
- const slices = [];
3097
- const legend = [];
3098
- buckets.forEach((b, i) => {
3099
- const startAngle = cumulative / total * Math.PI * 2 - Math.PI / 2;
3100
- const endAngle = (cumulative + b.count) / total * Math.PI * 2 - Math.PI / 2;
3101
- cumulative += b.count;
3102
- const x1 = cx + Math.cos(startAngle) * r;
3103
- const y1 = cy + Math.sin(startAngle) * r;
3104
- const x2 = cx + Math.cos(endAngle) * r;
3105
- const y2 = cy + Math.sin(endAngle) * r;
3106
- const ix1 = cx + Math.cos(endAngle) * innerR;
3107
- const iy1 = cy + Math.sin(endAngle) * innerR;
3108
- const ix2 = cx + Math.cos(startAngle) * innerR;
3109
- const iy2 = cy + Math.sin(startAngle) * innerR;
3110
- const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
3111
- const color = COLORS.series[i % COLORS.series.length];
3112
- if (b.count > 0) {
3113
- slices.push(`<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z" fill="${color}" />`);
3114
- legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
3115
- }
3116
- });
3569
+ const max = Math.max(...buckets.map((b) => b.count), 1);
3570
+ const rows = buckets.map((b) => {
3571
+ const pct = b.count / max * 100;
3572
+ const tone = SOURCE_CATEGORY_TONE[b.category] ?? "neutral";
3573
+ const color = tone === "negative" ? COLORS.negative : tone === "caution" ? COLORS.caution : COLORS.accent;
3574
+ return `
3575
+ <div class="source-bar-row">
3576
+ <div class="source-bar-label">${escapeHtml(b.label)}</div>
3577
+ <div class="source-bar-track">
3578
+ <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
3579
+ </div>
3580
+ <div class="source-bar-value">${b.count} <span class="source-bar-pct">(${b.sharePct}%)</span></div>
3581
+ </div>`;
3582
+ }).join("");
3117
3583
  return `<div class="chart-card">
3118
- <h3>AI source categories</h3>
3119
- <div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
3120
- <svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
3121
- ${slices.join("")}
3122
- </svg>
3123
- <div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
3124
- </div>
3584
+ <h3>By source type</h3>
3585
+ <div class="source-bars">${rows}</div>
3586
+ </div>`;
3587
+ }
3588
+ function renderShareBars(heading, rows, countLabel) {
3589
+ const visibleRows = rows.filter((r) => r.count > 0 || r.sharePct > 0);
3590
+ if (visibleRows.length === 0) return "";
3591
+ const bars = visibleRows.map((r, index) => {
3592
+ const pct = Math.max(0, Math.min(100, r.sharePct));
3593
+ const color = r.color ?? COLORS.series[index % COLORS.series.length];
3594
+ return `
3595
+ <div class="source-bar-row">
3596
+ <div class="source-bar-label">${escapeHtml(r.label)}</div>
3597
+ <div class="source-bar-track">
3598
+ <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
3599
+ </div>
3600
+ <div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml(countLabel)} \xB7 ${r.sharePct}%</span></div>
3601
+ </div>`;
3602
+ }).join("");
3603
+ return `<div class="chart-card">
3604
+ <h3>${escapeHtml(heading)}</h3>
3605
+ <div class="source-bars">${bars}</div>
3125
3606
  </div>`;
3126
3607
  }
3127
3608
  function renderAiSourceOrigin(report) {
@@ -3132,24 +3613,28 @@ function renderAiSourceOrigin(report) {
3132
3613
  renderEmpty("No source data yet. Run a visibility sweep first.")
3133
3614
  );
3134
3615
  }
3616
+ const competitorBucket = origin.categories.find((c) => c.category === "competitor");
3617
+ const headlineFragment = competitorBucket ? `<p class="source-origin-headline"><strong>${competitorBucket.sharePct}%</strong> of citations went to tracked competitors (${competitorBucket.count} of ${origin.categories.reduce((s, c) => s + c.count, 0)}).</p>` : "";
3135
3618
  const rows = origin.topDomains.map((d) => `
3136
3619
  <tr>
3137
3620
  <td>${escapeHtml(d.domain)}</td>
3138
3621
  <td class="numeric">${d.count}</td>
3139
- <td>${d.isCompetitor ? '<span class="badge tone-negative">Competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
3622
+ <td>${d.isCompetitor ? '<span class="badge tone-negative">Tracked competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
3140
3623
  </tr>`).join("");
3141
- const table = origin.topDomains.length > 0 ? `<table class="report-table">
3142
- <thead><tr><th>Domain</th><th>Citations</th><th>Tag</th></tr></thead>
3143
- <tbody>${rows}</tbody>
3144
- </table>` : "";
3624
+ const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>Top sources</h3>
3625
+ <table class="report-table">
3626
+ <thead><tr><th>Domain</th><th class="numeric">Citations</th><th>Tag</th></tr></thead>
3627
+ <tbody>${rows}</tbody>
3628
+ </table>
3629
+ </div>` : "";
3145
3630
  return section(
3146
3631
  {
3147
3632
  id: "ai-source-origin",
3148
3633
  eyebrow: "Section 4",
3149
3634
  title: "AI Citation Sources",
3150
- intro: "Every external website AI engines cited as a source for your tracked keywords in the latest sweep \u2014 categorized by site type (Reddit, YouTube, news, etc.) on the left and ranked by citation count on the right. Your own domains are excluded; tracked competitors are flagged."
3635
+ intro: "External domains AI engines trusted most in the latest sweep."
3151
3636
  },
3152
- `${renderDonut(origin.categories)}${table}`
3637
+ `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
3153
3638
  );
3154
3639
  }
3155
3640
  function renderLineChart(points, color, title, height = 200) {
@@ -3201,13 +3686,16 @@ function renderGsc(report) {
3201
3686
  <td class="numeric">${q.avgPosition.toFixed(1)}</td>
3202
3687
  <td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
3203
3688
  </tr>`).join("");
3204
- const breakdownRows = gsc.categoryBreakdown.map((c) => `
3205
- <tr>
3206
- <td>${escapeHtml(c.category)}</td>
3207
- <td class="numeric">${formatNumber(c.clicks)}</td>
3208
- <td class="numeric">${formatNumber(c.impressions)}</td>
3209
- <td class="numeric">${c.sharePct}%</td>
3210
- </tr>`).join("");
3689
+ const categoryBars = renderShareBars(
3690
+ "Search demand by intent",
3691
+ gsc.categoryBreakdown.map((c, index) => ({
3692
+ label: c.category,
3693
+ count: c.clicks,
3694
+ sharePct: c.sharePct,
3695
+ color: COLORS.series[index % COLORS.series.length]
3696
+ })),
3697
+ "clicks"
3698
+ );
3211
3699
  const trendChart = renderLineChart(
3212
3700
  gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
3213
3701
  COLORS.accent,
@@ -3216,18 +3704,19 @@ function renderGsc(report) {
3216
3704
  const crossoverBlocks = [];
3217
3705
  if (gsc.trackedButNoGsc.length > 0) {
3218
3706
  crossoverBlocks.push(`<div class="chart-card"><h3>AEO queries without search demand</h3>
3219
- <p class="section-intro">Tracked AEO queries with no GSC impressions in this window \u2014 review whether they represent real search demand.</p>
3220
- <ul>${gsc.trackedButNoGsc.map((q) => `<li>${escapeHtml(q)}</li>`).join("")}</ul>
3707
+ <p class="section-intro">Review whether these still belong in the tracking set.</p>
3708
+ ${renderProofChips(gsc.trackedButNoGsc, 6)}
3221
3709
  </div>`);
3222
3710
  }
3223
3711
  if (gsc.gscButNotTracked.length > 0) {
3224
3712
  crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
3225
- <p class="section-intro">GSC top queries (by impressions) that aren't tracked in your AEO project \u2014 candidates to add as queries.</p>
3226
- <ul>${gsc.gscButNotTracked.map((q) => `<li>${escapeHtml(q)}</li>`).join("")}</ul>
3713
+ <p class="section-intro">High-impression candidates to add to AEO tracking.</p>
3714
+ ${renderProofChips(gsc.gscButNotTracked, 6)}
3227
3715
  </div>`);
3228
3716
  }
3717
+ const dateRange = gscDateRange(report);
3229
3718
  return section(
3230
- { id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: "Your site\u2019s performance in Google\u2019s regular (non-AI) search results \u2014 top queries that drove impressions, intent breakdown, and the click trend, sourced from Google Search Console for the most recent sync window." },
3719
+ { id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ""}.` },
3231
3720
  `<div class="metric-grid">
3232
3721
  <div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
3233
3722
  <div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
@@ -3241,12 +3730,7 @@ function renderGsc(report) {
3241
3730
  <tbody>${rows}</tbody>
3242
3731
  </table>
3243
3732
  </div>
3244
- <div class="chart-card"><h3>Category breakdown</h3>
3245
- <table class="report-table">
3246
- <thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
3247
- <tbody>${breakdownRows}</tbody>
3248
- </table>
3249
- </div>
3733
+ ${categoryBars}
3250
3734
  ${crossoverBlocks.join("\n")}`
3251
3735
  );
3252
3736
  }
@@ -3265,14 +3749,18 @@ function renderGa(report) {
3265
3749
  <td class="numeric">${formatNumber(p.users)}</td>
3266
3750
  <td class="numeric">${formatNumber(p.organicSessions)}</td>
3267
3751
  </tr>`).join("");
3268
- const channelRows = ga.channelBreakdown.map((c) => `
3269
- <tr>
3270
- <td>${escapeHtml(c.channel)}</td>
3271
- <td class="numeric">${formatNumber(c.sessions)}</td>
3272
- <td class="numeric">${c.sharePct}%</td>
3273
- </tr>`).join("");
3752
+ const channelBars = renderShareBars(
3753
+ "Channel mix",
3754
+ ga.channelBreakdown.map((c, index) => ({
3755
+ label: c.channel,
3756
+ count: c.sessions,
3757
+ sharePct: c.sharePct,
3758
+ color: COLORS.series[index % COLORS.series.length]
3759
+ })),
3760
+ "sessions"
3761
+ );
3274
3762
  return section(
3275
- { id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Total sessions and users on your site between ${formatDate(ga.periodStart)} and ${formatDate(ga.periodEnd)}, with the top landing pages and channel breakdown \u2014 sourced from Google Analytics 4.` },
3763
+ { id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` },
3276
3764
  `<div class="metric-grid">
3277
3765
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
3278
3766
  <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
@@ -3284,12 +3772,7 @@ function renderGa(report) {
3284
3772
  <tbody>${pageRows}</tbody>
3285
3773
  </table>
3286
3774
  </div>
3287
- <div class="chart-card"><h3>Channel breakdown</h3>
3288
- <table class="report-table">
3289
- <thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
3290
- <tbody>${channelRows}</tbody>
3291
- </table>
3292
- </div>`
3775
+ ${channelBars}`
3293
3776
  );
3294
3777
  }
3295
3778
  function renderSocial(report) {
@@ -3300,12 +3783,16 @@ function renderSocial(report) {
3300
3783
  renderEmpty("No social referral data yet.")
3301
3784
  );
3302
3785
  }
3303
- const channelRows = social.channels.map((c) => `
3304
- <tr>
3305
- <td>${escapeHtml(c.channelGroup)}</td>
3306
- <td class="numeric">${formatNumber(c.sessions)}</td>
3307
- <td class="numeric">${c.sharePct}%</td>
3308
- </tr>`).join("");
3786
+ const channelBars = renderShareBars(
3787
+ "Social channel mix",
3788
+ social.channels.map((c, index) => ({
3789
+ label: c.channelGroup,
3790
+ count: c.sessions,
3791
+ sharePct: c.sharePct,
3792
+ color: COLORS.series[index % COLORS.series.length]
3793
+ })),
3794
+ "sessions"
3795
+ );
3309
3796
  const campaignRows = social.topCampaigns.map((c) => `
3310
3797
  <tr>
3311
3798
  <td>${escapeHtml(c.source)}</td>
@@ -3313,18 +3800,13 @@ function renderSocial(report) {
3313
3800
  <td class="numeric">${formatNumber(c.sessions)}</td>
3314
3801
  </tr>`).join("");
3315
3802
  return section(
3316
- { id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Sessions on your site sent by social platforms (LinkedIn, Facebook, X, etc.) \u2014 paid versus organic split and the top campaigns that drove them. Sourced from Google Analytics 4." },
3803
+ { id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Social traffic split by channel and campaign." },
3317
3804
  `<div class="metric-grid">
3318
3805
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
3319
3806
  <div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
3320
3807
  <div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
3321
3808
  </div>
3322
- <div class="chart-card"><h3>Channel groups</h3>
3323
- <table class="report-table">
3324
- <thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
3325
- <tbody>${channelRows}</tbody>
3326
- </table>
3327
- </div>
3809
+ ${channelBars}
3328
3810
  <div class="chart-card"><h3>Top campaigns</h3>
3329
3811
  <table class="report-table">
3330
3812
  <thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
@@ -3341,13 +3823,16 @@ function renderAiReferrals(report) {
3341
3823
  renderEmpty("No AI referral traffic detected yet.")
3342
3824
  );
3343
3825
  }
3344
- const sourceRows = ai.bySource.map((s) => `
3345
- <tr>
3346
- <td>${escapeHtml(s.source)}</td>
3347
- <td class="numeric">${formatNumber(s.sessions)}</td>
3348
- <td class="numeric">${formatNumber(s.users)}</td>
3349
- <td class="numeric">${s.sharePct}%</td>
3350
- </tr>`).join("");
3826
+ const sourceBars = renderShareBars(
3827
+ "AI sessions by source",
3828
+ ai.bySource.map((s, index) => ({
3829
+ label: s.source,
3830
+ count: s.sessions,
3831
+ sharePct: s.sharePct,
3832
+ color: COLORS.series[(index + 2) % COLORS.series.length]
3833
+ })),
3834
+ "sessions"
3835
+ );
3351
3836
  const pageRows = ai.topLandingPages.map((p) => `
3352
3837
  <tr>
3353
3838
  <td class="page-cell">${formatLandingPageHtml(p.page)}</td>
@@ -3360,18 +3845,13 @@ function renderAiReferrals(report) {
3360
3845
  "AI referral sessions over time"
3361
3846
  );
3362
3847
  return section(
3363
- { id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Sessions on your site referred by AI answer engines (ChatGPT, Perplexity, Claude, Copilot, Gemini, etc.) \u2014 broken down by referrer with a daily trend and the top landing pages. Sourced from Google Analytics 4." },
3848
+ { id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Traffic arriving from AI answer engines." },
3364
3849
  `<div class="metric-grid">
3365
3850
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
3366
3851
  <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
3367
3852
  </div>
3368
3853
  ${trendChart}
3369
- <div class="chart-card"><h3>Sessions by source</h3>
3370
- <table class="report-table">
3371
- <thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
3372
- <tbody>${sourceRows}</tbody>
3373
- </table>
3374
- </div>
3854
+ ${sourceBars}
3375
3855
  <div class="chart-card"><h3>Top AI landing pages</h3>
3376
3856
  <table class="report-table">
3377
3857
  <thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
@@ -3406,7 +3886,7 @@ function renderIndexingHealth(report) {
3406
3886
  }).join("");
3407
3887
  const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
3408
3888
  return section(
3409
- { id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `What share of your tracked URLs are currently indexed in ${ih.provider === "google" ? "Google" : "Bing"} \u2014 sourced from ${ih.provider === "google" ? "Google Search Console URL Inspection" : "Bing Webmaster Tools URL Inspection"}. Pages absent from the index can\u2019t be retrieved by AI engines either.` },
3889
+ { id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `Pages absent from ${ih.provider === "google" ? "Google" : "Bing"} are harder for AI engines to retrieve.` },
3410
3890
  `<div class="metric-grid">
3411
3891
  <div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
3412
3892
  <div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
@@ -3442,15 +3922,15 @@ function renderCitationsTrend(report) {
3442
3922
  const rows = trend.map((t) => `
3443
3923
  <tr>
3444
3924
  <td>${formatDate(t.date)}</td>
3445
- <td class="numeric">${t.citationRate}%</td>
3925
+ <td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
3446
3926
  <td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
3447
3927
  </tr>`).join("");
3448
3928
  return section(
3449
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation rate across every visibility sweep \u2014 the share of (keyword \xD7 provider) pairs in each run where your domain appeared in the source list, with a per-provider breakdown beneath." },
3929
+ { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation coverage across completed visibility sweeps." },
3450
3930
  `${chart}
3451
3931
  <div class="chart-card"><h3>Run-by-run breakdown</h3>
3452
3932
  <table class="report-table">
3453
- <thead><tr><th>Run</th><th class="numeric">Overall rate</th><th>Per-provider rates</th></tr></thead>
3933
+ <thead><tr><th>Run</th><th class="numeric">Cited queries</th><th>Per-provider rates</th></tr></thead>
3454
3934
  <tbody>${rows}</tbody>
3455
3935
  </table>
3456
3936
  </div>`
@@ -3469,36 +3949,51 @@ function renderInsights(report) {
3469
3949
  const tone = severityTone(i.severity);
3470
3950
  const countChip = count > 1 ? ` <span class="badge tone-neutral">\xD7 ${count}</span>` : "";
3471
3951
  return `<tr>
3472
- <td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
3473
- <td>${escapeHtml(i.title)}${countChip}</td>
3474
- <td>${escapeHtml(i.query)}</td>
3475
- <td>${escapeHtml(i.provider)}</td>
3476
- <td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
3952
+ <td class="col-severity"><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
3953
+ <td class="col-title">${escapeHtml(i.title)}${countChip}</td>
3954
+ <td class="col-query">${escapeHtml(i.query)}</td>
3955
+ <td class="col-provider">${escapeHtml(i.provider)}</td>
3956
+ <td class="col-recommendation">${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
3477
3957
  </tr>`;
3478
3958
  }).join("");
3479
3959
  return section(
3480
- { id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Regressions (citations lost), gains (citations won), and opportunities surfaced by the intelligence engine across the most recent sweeps \u2014 ordered by severity and recurrence." },
3481
- `<table class="report-table">
3482
- <thead><tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th><th>Recommendation</th></tr></thead>
3960
+ { id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Regressions, gains, and recurring alerts ordered by severity." },
3961
+ `<table class="report-table insights-table">
3962
+ <thead><tr>
3963
+ <th class="col-severity">Severity</th>
3964
+ <th class="col-title">Title</th>
3965
+ <th class="col-query">Query</th>
3966
+ <th class="col-provider">Provider</th>
3967
+ <th class="col-recommendation">Recommendation</th>
3968
+ </tr></thead>
3483
3969
  <tbody>${rows}</tbody>
3484
3970
  </table>`
3485
3971
  );
3486
3972
  }
3487
3973
  function renderOpportunities(report) {
3488
- const opps = report.contentOpportunities;
3974
+ const opps = dedupeReportOpportunities(report);
3489
3975
  if (opps.length === 0) return "";
3490
3976
  const canonical = report.meta.project.canonicalDomain;
3977
+ const highlights = `<div class="opportunity-grid">
3978
+ ${opps.slice(0, 3).map((o) => `<article class="opportunity-card">
3979
+ <div class="opportunity-score" title="Opportunity score (0\u2013100, higher = stronger)">${Math.round(o.score)}<span class="opportunity-score-suffix">/100</span></div>
3980
+ <h3>${escapeHtml(o.query)}</h3>
3981
+ <p>${escapeHtml(contentActionLabel(o.action))} \xB7 ${escapeHtml(actionConfidenceLabel(o.actionConfidence))} confidence</p>
3982
+ ${renderProofChips(o.drivers, 2)}
3983
+ </article>`).join("")}
3984
+ </div>`;
3491
3985
  const rows = opps.slice(0, 10).map((o) => {
3492
- const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3986
+ const ourPage = o.ourBestPage ? `<a href="${escapeHtml(absolutizeProjectUrl(o.ourBestPage.url, canonical))}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
3493
3987
  const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3988
+ const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
3494
3989
  return `<tr>
3495
3990
  <td>${escapeHtml(o.query)}</td>
3496
- <td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
3497
- <td class="numeric">${Math.round(o.score)}</td>
3991
+ <td><span class="badge tone-neutral">${escapeHtml(contentActionLabel(o.action))}</span></td>
3992
+ <td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
3993
+ <td>${drivers}</td>
3498
3994
  <td>${ourPage}</td>
3499
3995
  <td>${winning}</td>
3500
- <td><span class="badge tone-neutral">${escapeHtml(o.demandSource)}</span></td>
3501
- <td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
3996
+ <td><span class="badge tone-neutral">${escapeHtml(actionConfidenceLabel(o.actionConfidence))}</span></td>
3502
3997
  </tr>`;
3503
3998
  }).join("");
3504
3999
  return section(
@@ -3506,10 +4001,36 @@ function renderOpportunities(report) {
3506
4001
  id: "content-opportunities",
3507
4002
  eyebrow: "Section 12",
3508
4003
  title: "Content Opportunities",
3509
- intro: "Queries where you have search demand or competitor citation pressure but aren\u2019t winning AI citations. Each row carries a suggested action (create / refresh / expand / add-schema). Top 10 shown."
4004
+ intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger."
4005
+ },
4006
+ `${highlights}<table class="report-table">
4007
+ <thead><tr><th>Query</th><th>Action</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
4008
+ <tbody>${rows}</tbody>
4009
+ </table>`
4010
+ );
4011
+ }
4012
+ function renderContentGaps(report) {
4013
+ const gaps = report.contentGaps;
4014
+ if (gaps.length === 0) return "";
4015
+ const rows = gaps.slice(0, 10).map((g) => {
4016
+ const competitorList = g.competitorDomains.slice(0, 5).map(escapeHtml).join(", ");
4017
+ const more = g.competitorDomains.length > 5 ? `, +${g.competitorDomains.length - 5} more` : "";
4018
+ return `<tr>
4019
+ <td>${escapeHtml(g.query)}</td>
4020
+ <td class="numeric">${g.competitorCount}</td>
4021
+ <td>${competitorList}${more}</td>
4022
+ <td class="numeric">${Math.round(g.missRate * 100)}%</td>
4023
+ </tr>`;
4024
+ }).join("");
4025
+ return section(
4026
+ {
4027
+ id: "content-gaps",
4028
+ eyebrow: "Section 13",
4029
+ title: "Content Gaps",
4030
+ intro: "Tracked queries where competitors are cited and the client is missing."
3510
4031
  },
3511
4032
  `<table class="report-table">
3512
- <thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Our page</th><th>Winning competitor</th><th>Demand</th><th>Confidence</th></tr></thead>
4033
+ <thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
3513
4034
  <tbody>${rows}</tbody>
3514
4035
  </table>`
3515
4036
  );
@@ -3518,7 +4039,7 @@ function renderRecommendedNextSteps(report) {
3518
4039
  const steps = report.recommendedNextSteps;
3519
4040
  if (steps.length === 0) {
3520
4041
  return section(
3521
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
4042
+ { id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
3522
4043
  renderEmpty("No outstanding actions.")
3523
4044
  );
3524
4045
  }
@@ -3529,17 +4050,157 @@ function renderRecommendedNextSteps(report) {
3529
4050
  <span class="rationale">${escapeHtml(s.rationale)}</span>
3530
4051
  </div>`).join("");
3531
4052
  return section(
3532
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps", intro: "Action items bucketed by horizon (immediate, short-term, medium-term), drawn from open insights and the highest-ranked content opportunities." },
4053
+ { id: "recommended-next-steps", eyebrow: "Section 14", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
3533
4054
  `<div class="steps">${items}</div>`
3534
4055
  );
3535
4056
  }
4057
+ function actionAudienceMatches(action, audience) {
4058
+ return action.audience === "both" || action.audience === audience;
4059
+ }
4060
+ function renderActionCards(actions) {
4061
+ if (actions.length === 0) return renderEmpty("No prioritized actions yet.");
4062
+ return `<div class="action-card-grid">
4063
+ ${actions.map((action, idx) => {
4064
+ const tone = reportActionTone(action);
4065
+ const why = action.why.length > 0 ? `<ul>${action.why.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
4066
+ const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
4067
+ const proof = renderProofChips(action.evidence.length > 0 ? action.evidence : action.why, 3);
4068
+ const details = why || evidence ? `<details class="action-details">
4069
+ <summary>Evidence details</summary>
4070
+ ${why ? `<div><strong>Why</strong>${why}</div>` : ""}
4071
+ ${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
4072
+ </details>` : "";
4073
+ return `<article class="action-card">
4074
+ <div class="action-head">
4075
+ <div class="action-rank" title="Impact rank \u2014 1 is the highest-leverage action">${idx + 1}</div>
4076
+ <div>
4077
+ <div class="action-meta">
4078
+ <span class="badge tone-${tone}">${escapeHtml(reportHorizonLabel(action.horizon))}</span>
4079
+ <span class="badge tone-neutral">${escapeHtml(reportActionCategoryLabel(action.category))}</span>
4080
+ <span class="badge tone-neutral">${escapeHtml(reportConfidenceLabel(action.confidence))} confidence</span>
4081
+ </div>
4082
+ <h3>${escapeHtml(action.title)}</h3>
4083
+ </div>
4084
+ </div>
4085
+ <p>${escapeHtml(action.action)}</p>
4086
+ ${proof}
4087
+ ${details}
4088
+ <div class="success-metric"><strong>Win condition:</strong> ${escapeHtml(action.successMetric)}</div>
4089
+ </article>`;
4090
+ }).join("")}
4091
+ </div>`;
4092
+ }
4093
+ function renderAudienceActionPlan(report, audience) {
4094
+ const rawActions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
4095
+ const actions = dedupeReportActions(report, rawActions);
4096
+ return section(
4097
+ {
4098
+ id: audience === "client" ? "client-action-plan" : "agency-action-plan",
4099
+ eyebrow: audience === "client" ? "Client actions" : "Agency actions",
4100
+ title: audience === "client" ? "What We Recommend Next" : "Agency Action Plan",
4101
+ intro: audience === "client" ? "The short list to approve and execute." : "The highest-leverage work, sorted by urgency and evidence strength."
4102
+ },
4103
+ renderActionCards(actions)
4104
+ );
4105
+ }
4106
+ function renderClientSummary(report) {
4107
+ const s = report.executiveSummary;
4108
+ const metrics = `<div class="metric-grid">
4109
+ <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>
4110
+ <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>
4111
+ <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>
4112
+ </div>`;
4113
+ 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>` : "";
4114
+ return section(
4115
+ {
4116
+ id: "client-summary",
4117
+ eyebrow: "Client summary",
4118
+ title: "How You're Appearing",
4119
+ intro: report.clientSummary.overview
4120
+ },
4121
+ `<div class="chart-card">
4122
+ <h3>${escapeHtml(report.clientSummary.headline)}</h3>
4123
+ <p class="source-origin-headline">${escapeHtml(report.clientSummary.overview)}</p>
4124
+ </div>
4125
+ ${metrics}
4126
+ ${notes}`
4127
+ );
4128
+ }
4129
+ function renderClientEvidenceSummary(report) {
4130
+ const evidenceCards = [];
4131
+ if (report.aiSourceOrigin.topDomains.length > 0) {
4132
+ evidenceCards.push(`<div class="diagnostic-card tone-neutral">
4133
+ <h3>Sources AI engines trust</h3>
4134
+ <p>These domains appeared most often as cited sources outside your owned domain.</p>
4135
+ <ul>${report.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `<li>${escapeHtml(d.domain)}: ${formatNumber(d.count)} citation${d.count === 1 ? "" : "s"}</li>`).join("")}</ul>
4136
+ </div>`);
4137
+ }
4138
+ if (report.gsc) {
4139
+ evidenceCards.push(`<div class="diagnostic-card tone-neutral">
4140
+ <h3>Search demand</h3>
4141
+ <p>Search Console shows ${formatNumber(report.gsc.totalImpressions)} impressions and ${formatNumber(report.gsc.totalClicks)} clicks in the report window.</p>
4142
+ <ul>${report.gsc.topQueries.slice(0, 5).map((q) => `<li>${escapeHtml(q.query)}: ${formatNumber(q.impressions)} impressions</li>`).join("")}</ul>
4143
+ </div>`);
4144
+ }
4145
+ if (report.indexingHealth) {
4146
+ const tone = report.indexingHealth.indexedPct >= 90 ? "positive" : report.indexingHealth.indexedPct >= 70 ? "caution" : "negative";
4147
+ evidenceCards.push(`<div class="diagnostic-card tone-${tone}">
4148
+ <h3>Indexing readiness</h3>
4149
+ <p>${report.indexingHealth.indexedPct}% of inspected URLs are indexed.</p>
4150
+ <ul><li>${formatNumber(report.indexingHealth.indexed)} indexed</li><li>${formatNumber(report.indexingHealth.notIndexed)} not indexed</li></ul>
4151
+ </div>`);
4152
+ }
4153
+ const opportunities = dedupeReportOpportunities(report);
4154
+ if (opportunities.length > 0) {
4155
+ evidenceCards.push(`<div class="diagnostic-card tone-caution">
4156
+ <h3>Content opportunities</h3>
4157
+ <p>Canonry found topics where better content could improve AI citations.</p>
4158
+ <ul>${opportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
4159
+ </div>`);
4160
+ }
4161
+ return section(
4162
+ {
4163
+ id: "client-evidence-summary",
4164
+ eyebrow: "Evidence",
4165
+ title: "Why This Is The Plan",
4166
+ intro: "A concise evidence view for the client summary. The agency report keeps the full matrices and detailed tables."
4167
+ },
4168
+ evidenceCards.length > 0 ? `<div class="diagnostics-grid">${evidenceCards.join("")}</div>` : renderEmpty("No supporting evidence sections are populated yet.")
4169
+ );
4170
+ }
4171
+ function renderAgencyDiagnostics(report) {
4172
+ const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== "Location caveat");
4173
+ const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
4174
+ ${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
4175
+ <h3>${escapeHtml(d.title)}</h3>
4176
+ <p>${escapeHtml(d.detail)}</p>
4177
+ ${renderProofChips(d.evidence, 3)}
4178
+ </div>`).join("")}
4179
+ </div>` : renderEmpty("No agency diagnostics available yet.");
4180
+ return section(
4181
+ {
4182
+ id: "agency-diagnostics",
4183
+ eyebrow: "Agency diagnostics",
4184
+ title: "Technical Diagnostics",
4185
+ intro: "Fast-read operator flags behind the action plan."
4186
+ },
4187
+ body
4188
+ );
4189
+ }
3536
4190
  function escapeJsonForScript(json) {
3537
4191
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3538
4192
  }
3539
4193
  function renderReportHtml(report, opts = {}) {
3540
- const title = opts.title ?? `Canonry report \u2014 ${report.meta.project.displayName}`;
3541
- const sections = [
4194
+ const audience = opts.audience ?? "agency";
4195
+ const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
4196
+ const sections = audience === "client" ? [
4197
+ renderClientSummary(report),
4198
+ renderAudienceActionPlan(report, "client"),
4199
+ renderClientEvidenceSummary(report)
4200
+ ].join("\n") : [
3542
4201
  renderExecutiveSummary(report),
4202
+ renderAudienceActionPlan(report, "agency"),
4203
+ renderAgencyDiagnostics(report),
3543
4204
  renderCitationScorecard(report),
3544
4205
  renderCompetitorLandscape(report),
3545
4206
  renderAiSourceOrigin(report),
@@ -3551,6 +4212,7 @@ function renderReportHtml(report, opts = {}) {
3551
4212
  renderCitationsTrend(report),
3552
4213
  renderInsights(report),
3553
4214
  renderOpportunities(report),
4215
+ renderContentGaps(report),
3554
4216
  renderRecommendedNextSteps(report)
3555
4217
  ].join("\n");
3556
4218
  const json = escapeJsonForScript(JSON.stringify(report));
@@ -3565,9 +4227,9 @@ function renderReportHtml(report, opts = {}) {
3565
4227
  <body>
3566
4228
  <div class="container">
3567
4229
  <header class="header">
3568
- <div class="eyebrow">AEO Report</div>
4230
+ <div class="eyebrow">${audience === "client" ? "AEO Client Summary" : "AEO Agency Report"}</div>
3569
4231
  <h1>${escapeHtml(report.meta.project.displayName)}</h1>
3570
- <div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
4232
+ <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>
3571
4233
  </header>
3572
4234
  ${sections}
3573
4235
  <footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
@@ -3580,7 +4242,7 @@ function renderReportHtml(report, opts = {}) {
3580
4242
  // ../api-routes/src/content-data.ts
3581
4243
  import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
3582
4244
  var RECENT_RUNS_WINDOW = 5;
3583
- function loadOrchestratorInput(db, project) {
4245
+ function loadOrchestratorInput(db, project, locationFilter = void 0) {
3584
4246
  const projectId = project.id;
3585
4247
  const ownDomain = normalizeDomain(project.canonicalDomain);
3586
4248
  const ownedDomains = parseJsonColumn(project.ownedDomains, []);
@@ -3589,7 +4251,7 @@ function loadOrchestratorInput(db, project) {
3589
4251
  const candidateQueryStrings = trackedQueries.filter(isBlogShapedQuery);
3590
4252
  const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
3591
4253
  const competitorSet = new Set(trackedCompetitors);
3592
- const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
4254
+ const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW, locationFilter);
3593
4255
  const latestRunId = recentRunIds[0] ?? "";
3594
4256
  const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
3595
4257
  const candidateQueries = buildCandidateQueries({
@@ -3614,6 +4276,7 @@ function loadOrchestratorInput(db, project) {
3614
4276
  ownDomain,
3615
4277
  competitors: trackedCompetitors,
3616
4278
  candidateQueries,
4279
+ queryIntentModifiers: buildQueryIntentModifiers(project, locationFilter),
3617
4280
  inventory,
3618
4281
  wpSchemaAudit: /* @__PURE__ */ new Map(),
3619
4282
  gaTrafficByPage,
@@ -3623,6 +4286,74 @@ function loadOrchestratorInput(db, project) {
3623
4286
  inProgressActions: /* @__PURE__ */ new Map()
3624
4287
  };
3625
4288
  }
4289
+ function buildQueryIntentModifiers(project, locationFilter) {
4290
+ if (locationFilter === void 0 || locationFilter === null) return [];
4291
+ const locations = parseJsonColumn(project.locations, []);
4292
+ const currentLocation = locations.find((location) => location.label === locationFilter);
4293
+ const raw = currentLocation ? [
4294
+ currentLocation.label,
4295
+ currentLocation.city,
4296
+ currentLocation.region,
4297
+ regionAbbreviation(currentLocation.region),
4298
+ currentLocation.country
4299
+ ] : [locationFilter];
4300
+ return [...new Set(raw.map((value) => value.trim().toLowerCase()).filter(Boolean))];
4301
+ }
4302
+ function regionAbbreviation(region) {
4303
+ return US_REGION_ABBREVIATIONS[region.trim().toLowerCase()] ?? "";
4304
+ }
4305
+ var US_REGION_ABBREVIATIONS = {
4306
+ alabama: "al",
4307
+ alaska: "ak",
4308
+ arizona: "az",
4309
+ arkansas: "ar",
4310
+ california: "ca",
4311
+ colorado: "co",
4312
+ connecticut: "ct",
4313
+ delaware: "de",
4314
+ florida: "fl",
4315
+ georgia: "ga",
4316
+ hawaii: "hi",
4317
+ idaho: "id",
4318
+ illinois: "il",
4319
+ indiana: "in",
4320
+ iowa: "ia",
4321
+ kansas: "ks",
4322
+ kentucky: "ky",
4323
+ louisiana: "la",
4324
+ maine: "me",
4325
+ maryland: "md",
4326
+ massachusetts: "ma",
4327
+ michigan: "mi",
4328
+ minnesota: "mn",
4329
+ mississippi: "ms",
4330
+ missouri: "mo",
4331
+ montana: "mt",
4332
+ nebraska: "ne",
4333
+ nevada: "nv",
4334
+ "new hampshire": "nh",
4335
+ "new jersey": "nj",
4336
+ "new mexico": "nm",
4337
+ "new york": "ny",
4338
+ "north carolina": "nc",
4339
+ "north dakota": "nd",
4340
+ ohio: "oh",
4341
+ oklahoma: "ok",
4342
+ oregon: "or",
4343
+ pennsylvania: "pa",
4344
+ "rhode island": "ri",
4345
+ "south carolina": "sc",
4346
+ "south dakota": "sd",
4347
+ tennessee: "tn",
4348
+ texas: "tx",
4349
+ utah: "ut",
4350
+ vermont: "vt",
4351
+ virginia: "va",
4352
+ washington: "wa",
4353
+ "west virginia": "wv",
4354
+ wisconsin: "wi",
4355
+ wyoming: "wy"
4356
+ };
3626
4357
  function listQueries(db, projectId) {
3627
4358
  const rows = db.select({ text: queries.query }).from(queries).where(eq12(queries.projectId, projectId)).all();
3628
4359
  return rows.map((r) => r.text);
@@ -3631,8 +4362,8 @@ function listCompetitorDomains(db, projectId) {
3631
4362
  const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq12(competitors.projectId, projectId)).all();
3632
4363
  return rows.map((r) => r.domain);
3633
4364
  }
3634
- function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
3635
- const rows = db.select({ id: runs.id }).from(runs).where(
4365
+ function listRecentAnswerVisibilityRunIds(db, projectId, limit, locationFilter) {
4366
+ const rows = db.select({ id: runs.id, location: runs.location }).from(runs).where(
3636
4367
  and3(
3637
4368
  eq12(runs.projectId, projectId),
3638
4369
  eq12(runs.kind, RunKinds["answer-visibility"]),
@@ -3641,8 +4372,9 @@ function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
3641
4372
  // no usable evidence.
3642
4373
  inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
3643
4374
  )
3644
- ).orderBy(desc5(runs.createdAt)).limit(limit).all();
3645
- return rows.map((r) => r.id);
4375
+ ).orderBy(desc5(runs.createdAt)).all();
4376
+ const filtered = locationFilter === void 0 ? rows : rows.filter((r) => (r.location ?? null) === locationFilter);
4377
+ return filtered.slice(0, limit).map((r) => r.id);
3646
4378
  }
3647
4379
  function lookupRunTimestamp(db, runId) {
3648
4380
  const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq12(runs.id, runId)).get();
@@ -3860,6 +4592,14 @@ var TOP_LANDING_PAGES_LIMIT = 20;
3860
4592
  var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
3861
4593
  var TOP_CAMPAIGN_LIMIT = 10;
3862
4594
  var INSIGHT_LOOKBACK_RUNS = 5;
4595
+ var REPORT_WINDOW_DAYS = 30;
4596
+ function windowStartDate(endDate, windowDays) {
4597
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endDate);
4598
+ if (!m) return endDate;
4599
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
4600
+ d.setUTCDate(d.getUTCDate() - (windowDays - 1));
4601
+ return d.toISOString().slice(0, 10);
4602
+ }
3863
4603
  function safeNum(value) {
3864
4604
  if (typeof value === "number") return value;
3865
4605
  if (typeof value === "string") {
@@ -3895,7 +4635,12 @@ function loadQueryLookup(db, projectId) {
3895
4635
  return { byId };
3896
4636
  }
3897
4637
  function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
3898
- const rows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
4638
+ const allRows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
4639
+ if (allRows.length === 0) return null;
4640
+ let maxDate = "";
4641
+ for (const r of allRows) if (r.date > maxDate) maxDate = r.date;
4642
+ const startDate = windowStartDate(maxDate, REPORT_WINDOW_DAYS);
4643
+ const rows = allRows.filter((r) => r.date >= startDate && r.date <= maxDate);
3899
4644
  if (rows.length === 0) return null;
3900
4645
  let totalClicks = 0;
3901
4646
  let totalImpressions = 0;
@@ -3941,11 +4686,15 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
3941
4686
  sharePct: totalClicks > 0 ? Math.round(agg.clicks / totalClicks * 100) : 0
3942
4687
  })).sort((a, b) => b.clicks - a.clicks);
3943
4688
  const trend = [...trendAgg.entries()].map(([date, agg]) => ({ date, clicks: agg.clicks, impressions: agg.impressions })).sort((a, b) => a.date.localeCompare(b.date));
4689
+ const periodStart = trend[0]?.date ?? "";
4690
+ const periodEnd = trend.at(-1)?.date ?? "";
3944
4691
  const trackedSet = new Set(trackedQueries.map((q) => q.toLowerCase()));
3945
4692
  const gscQuerySet = new Set([...queryAgg.keys()].map((q) => q.toLowerCase()));
3946
4693
  const trackedButNoGsc = trackedQueries.filter((q) => !gscQuerySet.has(q.toLowerCase())).sort();
3947
4694
  const gscButNotTracked = [...queryAgg.entries()].filter(([q]) => !trackedSet.has(q.toLowerCase())).filter(([q]) => categorizeQuery(q, projectDisplayName, canonicalDomain) !== "brand").sort((a, b) => b[1].impressions - a[1].impressions).map(([q]) => q).slice(0, TOP_QUERIES_LIMIT);
3948
4695
  return {
4696
+ periodStart,
4697
+ periodEnd,
3949
4698
  totalClicks,
3950
4699
  totalImpressions,
3951
4700
  ctr,
@@ -3958,14 +4707,24 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
3958
4707
  };
3959
4708
  }
3960
4709
  function buildGaSection(db, projectId) {
3961
- const summaryRow = db.select().from(gaTrafficSummaries).where(eq13(gaTrafficSummaries.projectId, projectId)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
3962
- const snapshotRows = db.select().from(gaTrafficSnapshots).where(eq13(gaTrafficSnapshots.projectId, projectId)).all();
3963
- if (!summaryRow && snapshotRows.length === 0) return null;
3964
- const totalSessions = summaryRow?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
3965
- const totalUsers = summaryRow?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
3966
- const totalOrganicSessions = summaryRow?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
4710
+ const windowSummary = db.select().from(gaTrafficWindowSummaries).where(
4711
+ and4(
4712
+ eq13(gaTrafficWindowSummaries.projectId, projectId),
4713
+ eq13(gaTrafficWindowSummaries.windowKey, "30d")
4714
+ )
4715
+ ).limit(1).get();
4716
+ const fallbackSummary = windowSummary ? null : db.select().from(gaTrafficSummaries).where(eq13(gaTrafficSummaries.projectId, projectId)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
4717
+ const allSnapshotRows = db.select().from(gaTrafficSnapshots).where(eq13(gaTrafficSnapshots.projectId, projectId)).all();
4718
+ if (!windowSummary && !fallbackSummary && allSnapshotRows.length === 0) return null;
4719
+ let snapshotMaxDate = "";
4720
+ for (const r of allSnapshotRows) if (r.date > snapshotMaxDate) snapshotMaxDate = r.date;
4721
+ const snapshotStartDate = snapshotMaxDate ? windowStartDate(snapshotMaxDate, REPORT_WINDOW_DAYS) : "";
4722
+ const snapshotRows = snapshotStartDate ? allSnapshotRows.filter((r) => r.date >= snapshotStartDate && r.date <= snapshotMaxDate) : allSnapshotRows;
4723
+ const totalSessions = windowSummary?.totalSessions ?? fallbackSummary?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
4724
+ const totalUsers = windowSummary?.totalUsers ?? fallbackSummary?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
4725
+ const totalOrganicSessions = windowSummary?.totalOrganicSessions ?? fallbackSummary?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
3967
4726
  const pageAgg = /* @__PURE__ */ new Map();
3968
- let directSessions = 0;
4727
+ let directSessions = windowSummary?.totalDirectSessions ?? 0;
3969
4728
  for (const r of snapshotRows) {
3970
4729
  const page = r.landingPageNormalized ?? r.landingPage;
3971
4730
  const existing = pageAgg.get(page) ?? { sessions: 0, users: 0, organic: 0 };
@@ -3973,7 +4732,7 @@ function buildGaSection(db, projectId) {
3973
4732
  existing.users += r.users;
3974
4733
  existing.organic += r.organicSessions;
3975
4734
  pageAgg.set(page, existing);
3976
- if (r.directSessions != null) directSessions += r.directSessions;
4735
+ if (!windowSummary && r.directSessions != null) directSessions += r.directSessions;
3977
4736
  }
3978
4737
  const topLandingPages = [...pageAgg.entries()].map(([page, data]) => ({
3979
4738
  page,
@@ -4001,12 +4760,14 @@ function buildGaSection(db, projectId) {
4001
4760
  }
4002
4761
  }
4003
4762
  }
4763
+ const periodStart = windowSummary?.periodStart ?? (snapshotStartDate || fallbackSummary?.periodStart || "");
4764
+ const periodEnd = windowSummary?.periodEnd ?? (snapshotMaxDate || fallbackSummary?.periodEnd || "");
4004
4765
  return {
4005
4766
  totalSessions,
4006
4767
  totalUsers,
4007
4768
  totalOrganicSessions,
4008
- periodStart: summaryRow?.periodStart ?? "",
4009
- periodEnd: summaryRow?.periodEnd ?? "",
4769
+ periodStart,
4770
+ periodEnd,
4010
4771
  topLandingPages,
4011
4772
  channelBreakdown
4012
4773
  };
@@ -4129,27 +4890,33 @@ function buildIndexingHealth(db, projectId) {
4129
4890
  }
4130
4891
  return null;
4131
4892
  }
4132
- function buildCitationsTrend(db, projectId, queryLookup) {
4133
- const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all();
4893
+ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
4894
+ const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter);
4895
+ const totalQueries = queryLookup.byId.size;
4134
4896
  const points = [];
4135
4897
  for (const run of visibilityRuns) {
4136
4898
  if (run.status !== RunStatuses.completed) continue;
4137
4899
  const snaps = loadSnapshotsForRun(db, run.id);
4138
4900
  if (snaps.length === 0) continue;
4139
- let cited = 0;
4901
+ const citedQueryIds = /* @__PURE__ */ new Set();
4902
+ const mentionedQueryIds = /* @__PURE__ */ new Set();
4140
4903
  let considered = 0;
4141
4904
  const providerCounts = /* @__PURE__ */ new Map();
4142
4905
  for (const snap of snaps) {
4143
4906
  if (!queryLookup.byId.has(snap.queryId)) continue;
4144
4907
  considered++;
4145
- if (snap.citationState === CitationStates.cited) cited++;
4908
+ if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
4909
+ if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
4146
4910
  const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
4147
4911
  counts.total++;
4148
4912
  if (snap.citationState === CitationStates.cited) counts.cited++;
4149
4913
  providerCounts.set(snap.provider, counts);
4150
4914
  }
4151
4915
  if (considered === 0) continue;
4152
- const citationRate = Math.round(cited / considered * 100);
4916
+ const citedQueryCount = citedQueryIds.size;
4917
+ const mentionedQueryCount = mentionedQueryIds.size;
4918
+ const citationRate = totalQueries > 0 ? Math.round(citedQueryCount / totalQueries * 100) : 0;
4919
+ const mentionRate = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
4153
4920
  const providerRates = [...providerCounts.entries()].map(([provider, counts]) => ({
4154
4921
  provider,
4155
4922
  citationRate: counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0
@@ -4158,20 +4925,24 @@ function buildCitationsTrend(db, projectId, queryLookup) {
4158
4925
  runId: run.id,
4159
4926
  date: run.finishedAt ?? run.createdAt,
4160
4927
  citationRate,
4928
+ citedQueryCount,
4929
+ totalQueryCount: totalQueries,
4930
+ mentionRate,
4931
+ mentionedQueryCount,
4161
4932
  providerRates
4162
4933
  });
4163
4934
  }
4164
4935
  points.sort((a, b) => a.date.localeCompare(b.date));
4165
4936
  return points;
4166
4937
  }
4167
- function buildInsightList(db, projectId) {
4168
- const recentRunIds = db.select({ id: runs.id }).from(runs).where(
4938
+ function buildInsightList(db, projectId, locationFilter) {
4939
+ const recentRunIds = db.select({ id: runs.id, location: runs.location }).from(runs).where(
4169
4940
  and4(
4170
4941
  eq13(runs.projectId, projectId),
4171
4942
  eq13(runs.kind, RunKinds["answer-visibility"]),
4172
4943
  or2(eq13(runs.status, RunStatuses.completed), eq13(runs.status, RunStatuses.partial))
4173
4944
  )
4174
- ).orderBy(desc6(runs.createdAt)).limit(INSIGHT_LOOKBACK_RUNS).all().map((r) => r.id);
4945
+ ).orderBy(desc6(runs.createdAt)).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter).slice(0, INSIGHT_LOOKBACK_RUNS).map((r) => r.id);
4175
4946
  if (recentRunIds.length === 0) return [];
4176
4947
  const rows = db.select().from(insights).where(and4(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
4177
4948
  const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -4243,7 +5014,7 @@ function buildRecommendedNextSteps(insightList) {
4243
5014
  }
4244
5015
  return steps;
4245
5016
  }
4246
- function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
5017
+ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
4247
5018
  const findings = [];
4248
5019
  if (trendsPoints.length > 0) {
4249
5020
  const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
@@ -4266,8 +5037,10 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
4266
5037
  break;
4267
5038
  }
4268
5039
  }
5040
+ const queryNoun = totalQueryCount === 1 ? "query" : "queries";
5041
+ const ratioFragment = totalQueryCount > 0 ? ` (${citedQueryCount} of ${totalQueryCount} ${queryNoun} cited)` : "";
4269
5042
  findings.push({
4270
- title: `Citation rate at ${citationRate}%`,
5043
+ title: `Citation rate at ${citationRate}%${ratioFragment}`,
4271
5044
  detail,
4272
5045
  tone
4273
5046
  });
@@ -4290,6 +5063,317 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
4290
5063
  }
4291
5064
  return findings.slice(0, 5);
4292
5065
  }
5066
+ function buildLocationMeta(runLocationLabel, configuredLocations) {
5067
+ if (!runLocationLabel) return null;
5068
+ const match = configuredLocations.find((loc) => loc.label === runLocationLabel);
5069
+ const others = configuredLocations.map((loc) => loc.label).filter((label) => label !== runLocationLabel);
5070
+ return {
5071
+ label: runLocationLabel,
5072
+ city: match?.city ?? "",
5073
+ region: match?.region ?? "",
5074
+ country: match?.country ?? "",
5075
+ otherConfiguredLabels: others
5076
+ };
5077
+ }
5078
+ function buildProviderLocationHandling(providersInRun) {
5079
+ return [...providersInRun].sort().map((provider) => {
5080
+ const handling = getProviderLocationHandling(provider);
5081
+ return {
5082
+ provider,
5083
+ treatment: handling.treatment,
5084
+ description: handling.description
5085
+ };
5086
+ });
5087
+ }
5088
+ function compactList(items, limit = 3) {
5089
+ const visible = items.slice(0, limit);
5090
+ const extra = items.length - visible.length;
5091
+ return extra > 0 ? `${visible.join(", ")}, +${extra} more` : visible.join(", ");
5092
+ }
5093
+ function contentActionVerb(action) {
5094
+ switch (action) {
5095
+ case "create":
5096
+ return "Create";
5097
+ case "expand":
5098
+ return "Expand";
5099
+ case "refresh":
5100
+ return "Refresh";
5101
+ case "add-schema":
5102
+ return "Add schema to";
5103
+ }
5104
+ }
5105
+ function confidenceFromEvidence(count) {
5106
+ if (count >= 3) return "high";
5107
+ if (count >= 1) return "medium";
5108
+ return "low";
5109
+ }
5110
+ function actionAudienceMatches2(action, audience) {
5111
+ return action.audience === "both" || action.audience === audience;
5112
+ }
5113
+ function buildReportActionPlan(input) {
5114
+ const actions = [];
5115
+ if (input.competitorDomains.length === 0 && input.aiSourceOrigin.topDomains.length > 0) {
5116
+ const topDomains = input.aiSourceOrigin.topDomains.slice(0, 5);
5117
+ actions.push({
5118
+ audience: "both",
5119
+ priority: 10,
5120
+ horizon: "immediate",
5121
+ category: "competitors",
5122
+ title: "Define the competitor set Canonry should benchmark against",
5123
+ action: "Review the recurring external source domains and add the true competitors before the next sweep.",
5124
+ why: [
5125
+ "The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.",
5126
+ "A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients."
5127
+ ],
5128
+ evidence: topDomains.map((d) => `${d.domain} appeared in ${d.count} cited source${d.count === 1 ? "" : "s"}`),
5129
+ successMetric: "Next report separates tracked competitors from independent source domains in the competitor landscape.",
5130
+ confidence: confidenceFromEvidence(topDomains.length)
5131
+ });
5132
+ }
5133
+ for (const [index, opportunity] of input.contentOpportunities.slice(0, 2).entries()) {
5134
+ const verb = contentActionVerb(opportunity.action);
5135
+ const target = opportunity.ourBestPage?.url ?? `a new page for "${opportunity.query}"`;
5136
+ const evidence = [
5137
+ `Opportunity score ${Math.round(opportunity.score)} with ${opportunity.actionConfidence} confidence`,
5138
+ `Demand source: ${opportunity.demandSource}`
5139
+ ];
5140
+ if (opportunity.winningCompetitor) {
5141
+ evidence.push(`${opportunity.winningCompetitor.domain} is the current winning cited source`);
5142
+ }
5143
+ if (opportunity.ourBestPage) {
5144
+ evidence.push(`Best matching owned page: ${opportunity.ourBestPage.url}`);
5145
+ } else {
5146
+ evidence.push("No matching owned page was found");
5147
+ }
5148
+ actions.push({
5149
+ audience: "both",
5150
+ priority: 20 + index,
5151
+ horizon: opportunity.actionConfidence === "high" ? "short-term" : "medium-term",
5152
+ category: "content",
5153
+ title: `${verb} content for "${opportunity.query}"`,
5154
+ action: opportunity.ourBestPage ? `${verb} ${target} so it directly answers the tracked query and cites the strongest supporting evidence.` : `${verb} ${target} that directly answers the query and earns citations from AI answer engines.`,
5155
+ why: opportunity.drivers.length > 0 ? opportunity.drivers : ["Canonry ranked this as a content opportunity from search-demand and citation evidence."],
5156
+ evidence,
5157
+ successMetric: `A future sweep cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
5158
+ confidence: opportunity.actionConfidence
5159
+ });
5160
+ }
5161
+ if (input.indexingHealth && input.indexingHealth.total > 0 && input.indexingHealth.indexedPct < 70) {
5162
+ const ih = input.indexingHealth;
5163
+ const evidence = [
5164
+ `${ih.indexedPct}% indexed (${ih.indexed}/${ih.total})`,
5165
+ `${ih.notIndexed} not indexed${ih.deindexed > 0 ? `, ${ih.deindexed} deindexed` : ""}`
5166
+ ];
5167
+ actions.push({
5168
+ audience: "both",
5169
+ priority: 30,
5170
+ horizon: "immediate",
5171
+ category: "indexing",
5172
+ title: "Fix indexing coverage before expanding the content plan",
5173
+ action: "Audit the not-indexed tracked URLs, resolve crawl/index blockers, and resubmit priority pages.",
5174
+ why: [
5175
+ "Pages missing from the search index are less likely to be retrieved or cited by AI answer engines.",
5176
+ "Indexing issues can hide otherwise strong content from both search and AI systems."
5177
+ ],
5178
+ evidence,
5179
+ successMetric: "Indexed share moves above 80% for tracked URLs and priority pages are eligible for retrieval.",
5180
+ confidence: ih.total >= 5 ? "high" : "medium"
5181
+ });
5182
+ }
5183
+ const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
5184
+ if (zeroCitationProviders.length > 0) {
5185
+ actions.push({
5186
+ audience: "agency",
5187
+ priority: 40,
5188
+ horizon: "short-term",
5189
+ category: "provider",
5190
+ title: "Diagnose providers with zero citations",
5191
+ action: "Inspect zero-citation provider answers and compare their cited domains against the pages currently available on the client site.",
5192
+ why: [
5193
+ "Provider-level misses show where one model family is not retrieving the client even when others might.",
5194
+ "This points the agency toward provider-specific evidence gaps instead of a generic content recommendation."
5195
+ ],
5196
+ evidence: zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`),
5197
+ successMetric: "At least one zero-citation provider cites the client on a priority query in a later sweep.",
5198
+ confidence: "high"
5199
+ });
5200
+ }
5201
+ if (input.gsc && (input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0)) {
5202
+ const evidence = [];
5203
+ if (input.gsc.trackedButNoGsc.length > 0) {
5204
+ evidence.push(`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`);
5205
+ }
5206
+ if (input.gsc.gscButNotTracked.length > 0) {
5207
+ evidence.push(`Search demand not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`);
5208
+ }
5209
+ actions.push({
5210
+ audience: "agency",
5211
+ priority: 50,
5212
+ horizon: "short-term",
5213
+ category: "search-demand",
5214
+ title: "Align tracked AEO queries with search demand",
5215
+ action: "Prune or relabel tracked queries with no search demand and add high-impression non-brand GSC queries to the AEO tracking set.",
5216
+ why: [
5217
+ "The strongest report actions come from overlap between real search demand and AI citation gaps.",
5218
+ "Mismatch here can make the client report feel interesting but hard to act on."
5219
+ ],
5220
+ evidence,
5221
+ successMetric: "Next report has fewer no-demand tracked queries and includes the highest-impression non-brand GSC candidates.",
5222
+ confidence: evidence.length > 1 ? "high" : "medium"
5223
+ });
5224
+ }
5225
+ if (input.contentGaps.length > 0) {
5226
+ const topGap = input.contentGaps[0];
5227
+ actions.push({
5228
+ audience: "agency",
5229
+ priority: 60,
5230
+ horizon: "medium-term",
5231
+ category: "content",
5232
+ title: "Close competitor-cited content gaps",
5233
+ action: "Map the top missing queries to owned pages or new briefs, starting with the gaps where multiple competitors are already cited.",
5234
+ why: [
5235
+ "These are explicit places where AI engines found competitor sources but not the client.",
5236
+ "They are stronger evidence than a generic topic list because the model is already retrieving competing content."
5237
+ ],
5238
+ evidence: [
5239
+ `"${topGap.query}" missed at ${Math.round(topGap.missRate * 100)}% with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
5240
+ `Cited competitors: ${compactList(topGap.competitorDomains)}`
5241
+ ],
5242
+ successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
5243
+ confidence: topGap.competitorCount >= 2 ? "high" : "medium"
5244
+ });
5245
+ }
5246
+ if (input.reportLocation && input.reportLocation.otherConfiguredLabels.length > 0) {
5247
+ const ignoredProviders = input.providerLocationHandling.filter((p) => p.treatment === "ignored" || p.treatment === "browser-geo").map((p) => p.provider);
5248
+ const evidence = [
5249
+ `Current report location: ${input.reportLocation.label}`,
5250
+ `Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`
5251
+ ];
5252
+ if (ignoredProviders.length > 0) {
5253
+ evidence.push(`Providers with weak/indirect location handling: ${compactList(ignoredProviders)}`);
5254
+ }
5255
+ actions.push({
5256
+ audience: "agency",
5257
+ priority: 70,
5258
+ horizon: "medium-term",
5259
+ category: "location",
5260
+ title: "Keep location-scoped reporting separate by market",
5261
+ action: "Run and compare separate sweeps for each configured location before making market-level recommendations.",
5262
+ why: [
5263
+ "A multi-location client can appear differently by market.",
5264
+ "Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story."
5265
+ ],
5266
+ evidence,
5267
+ successMetric: "Each configured market has its own current sweep and trend before cross-market decisions are made.",
5268
+ confidence: "high"
5269
+ });
5270
+ }
5271
+ if (actions.length === 0) {
5272
+ actions.push({
5273
+ audience: "both",
5274
+ priority: 90,
5275
+ horizon: "short-term",
5276
+ category: "monitoring",
5277
+ title: "Keep monitoring citation and mention coverage",
5278
+ action: "Run the next scheduled visibility sweep and watch for citation gains, losses, and provider-specific misses.",
5279
+ why: [
5280
+ "No urgent corrective action surfaced from the current evidence.",
5281
+ "AEO performance is directional; repeated sweeps are needed before overreacting to a single sample."
5282
+ ],
5283
+ evidence: ["No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report."],
5284
+ successMetric: "Coverage stays stable or improves across the next trend window.",
5285
+ confidence: "medium"
5286
+ });
5287
+ }
5288
+ return actions.sort((a, b) => a.priority - b.priority).slice(0, 10);
5289
+ }
5290
+ function trendSentence(trend) {
5291
+ switch (trend) {
5292
+ case "up":
5293
+ return "Citation coverage improved versus the prior comparable sweep.";
5294
+ case "down":
5295
+ return "Citation coverage declined versus the prior comparable sweep.";
5296
+ case "flat":
5297
+ return "Citation coverage is flat versus the prior comparable sweep.";
5298
+ case "unknown":
5299
+ return "There is not enough comparable run history yet to call a trend.";
5300
+ }
5301
+ }
5302
+ function buildClientSummary(reportLike) {
5303
+ const s = reportLike.executiveSummary;
5304
+ const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
5305
+ const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a visibility sweep yet";
5306
+ const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "Canonry needs at least one completed visibility sweep before it can summarize how the brand appears in AI answers.";
5307
+ const confidenceNotes = [];
5308
+ if (s.totalQueryCount === 0) {
5309
+ confidenceNotes.push("Confidence is low until the first tracked query sweep completes.");
5310
+ } else if (s.totalQueryCount < 5) {
5311
+ confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
5312
+ }
5313
+ if (isTrendBaseline(reportLike.citationsTrend)) {
5314
+ confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable sweeps are needed for a stable trend.`);
5315
+ }
5316
+ if (!reportLike.gsc) {
5317
+ confidenceNotes.push("Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.");
5318
+ }
5319
+ if (reportLike.reportLocation) {
5320
+ confidenceNotes.push(`This summary is scoped to the ${reportLike.reportLocation.label} run location.`);
5321
+ }
5322
+ return {
5323
+ headline,
5324
+ overview,
5325
+ actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches2(a, "client")).slice(0, 3),
5326
+ confidenceNotes
5327
+ };
5328
+ }
5329
+ function buildAgencyDiagnostics(input) {
5330
+ const diagnostics = [];
5331
+ const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
5332
+ diagnostics.push({
5333
+ title: "Provider citation coverage",
5334
+ detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length} provider${zeroCitationProviders.length === 1 ? "" : "s"} returned zero client citations in the latest sweep.` : "Every provider with completed snapshots produced at least one client citation or no provider data is available yet.",
5335
+ severity: zeroCitationProviders.length > 0 ? "negative" : "positive",
5336
+ evidence: zeroCitationProviders.length > 0 ? zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount}`) : input.citationScorecard.providerRates.map((p) => `${p.provider}: ${p.citedCount}/${p.totalCount}`)
5337
+ });
5338
+ diagnostics.push({
5339
+ title: "AI source domains",
5340
+ detail: input.aiSourceOrigin.topDomains.length > 0 ? "Repeated external source domains show what AI engines are currently trusting for this topic set." : "No external source-domain evidence is available from the latest sweep yet.",
5341
+ severity: input.aiSourceOrigin.topDomains.length > 0 ? "neutral" : "caution",
5342
+ evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `${d.domain}: ${d.count}`)
5343
+ });
5344
+ if (input.gsc) {
5345
+ diagnostics.push({
5346
+ title: "GSC query mismatch",
5347
+ detail: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "The tracked AEO query set and real search demand are not fully aligned." : "Tracked AEO queries and high-impression non-brand GSC queries are aligned for the current window.",
5348
+ severity: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "caution" : "positive",
5349
+ evidence: [
5350
+ ...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`] : [],
5351
+ ...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`] : []
5352
+ ]
5353
+ });
5354
+ }
5355
+ if (input.indexingHealth) {
5356
+ diagnostics.push({
5357
+ title: "Indexing health",
5358
+ detail: `${input.indexingHealth.indexedPct}% of inspected URLs are indexed in ${input.indexingHealth.provider ?? "the connected provider"}.`,
5359
+ severity: input.indexingHealth.indexedPct >= 90 ? "positive" : input.indexingHealth.indexedPct >= 70 ? "caution" : "negative",
5360
+ evidence: [
5361
+ `${input.indexingHealth.indexed}/${input.indexingHealth.total} indexed`,
5362
+ `${input.indexingHealth.notIndexed} not indexed`
5363
+ ]
5364
+ });
5365
+ }
5366
+ diagnostics.push({
5367
+ title: "Content opportunity pipeline",
5368
+ detail: input.contentOpportunities.length > 0 ? `${input.contentOpportunities.length} ranked content opportunit${input.contentOpportunities.length === 1 ? "y" : "ies"} and ${input.contentGaps.length} content gap${input.contentGaps.length === 1 ? "" : "s"} are available.` : "No ranked content opportunities are available from the current evidence.",
5369
+ severity: input.contentOpportunities.length > 0 ? "caution" : "neutral",
5370
+ evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
5371
+ });
5372
+ return {
5373
+ priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
5374
+ diagnostics
5375
+ };
5376
+ }
4293
5377
  function buildProjectReport(db, projectName) {
4294
5378
  const project = resolveProject(db, projectName);
4295
5379
  const queryLookup = loadQueryLookup(db, project.id);
@@ -4299,6 +5383,7 @@ function buildProjectReport(db, projectName) {
4299
5383
  (r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
4300
5384
  ) ?? visibilityRuns[0];
4301
5385
  const latestSnapshots = latestRun ? loadSnapshotsForRun(db, latestRun.id) : [];
5386
+ const latestRunLocation = latestRun?.location ?? null;
4302
5387
  const competitorRows = db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
4303
5388
  const competitorDomains = competitorRows.map((c) => c.domain);
4304
5389
  const ownedDomains = parseJsonColumn(project.ownedDomains, []);
@@ -4330,9 +5415,9 @@ function buildProjectReport(db, projectName) {
4330
5415
  const socialSection = buildSocialReferrals(db, project.id);
4331
5416
  const aiReferralsSection = buildAiReferrals(db, project.id);
4332
5417
  const indexingHealthSection = buildIndexingHealth(db, project.id);
4333
- const citationsTrend = buildCitationsTrend(db, project.id, queryLookup);
4334
- const insightList = buildInsightList(db, project.id);
4335
- const orchestratorInput = loadOrchestratorInput(db, project);
5418
+ const citationsTrend = buildCitationsTrend(db, project.id, queryLookup, latestRunLocation);
5419
+ const insightList = buildInsightList(db, project.id, latestRunLocation);
5420
+ const orchestratorInput = loadOrchestratorInput(db, project, latestRunLocation);
4336
5421
  const contentOpportunities = buildContentTargetRows(orchestratorInput);
4337
5422
  const contentGaps = buildContentGapRows(orchestratorInput);
4338
5423
  const groundingSources = buildContentSourceRows(orchestratorInput);
@@ -4341,14 +5426,18 @@ function buildProjectReport(db, projectName) {
4341
5426
  contentOpportunities,
4342
5427
  insightDerivedSteps
4343
5428
  );
4344
- let latestCited = 0;
4345
- let latestConsidered = 0;
5429
+ const totalQueryCount = queryLookup.byId.size;
5430
+ const citedQueryIds = /* @__PURE__ */ new Set();
5431
+ const mentionedQueryIds = /* @__PURE__ */ new Set();
4346
5432
  for (const snap of latestSnapshots) {
4347
5433
  if (!queryLookup.byId.has(snap.queryId)) continue;
4348
- latestConsidered++;
4349
- if (snap.citationState === CitationStates.cited) latestCited++;
5434
+ if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
5435
+ if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
4350
5436
  }
4351
- const citationRate = latestConsidered > 0 ? Math.round(latestCited / latestConsidered * 100) : 0;
5437
+ const citedQueryCount = citedQueryIds.size;
5438
+ const mentionedQueryCount = mentionedQueryIds.size;
5439
+ const citationRate = totalQueryCount > 0 ? Math.round(citedQueryCount / totalQueryCount * 100) : 0;
5440
+ const mentionRate = totalQueryCount > 0 ? Math.round(mentionedQueryCount / totalQueryCount * 100) : 0;
4352
5441
  const trendBaseline = isTrendBaseline(citationsTrend);
4353
5442
  const latestPoint = citationsTrend.at(-1);
4354
5443
  const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
@@ -4365,6 +5454,8 @@ function buildProjectReport(db, projectName) {
4365
5454
  }
4366
5455
  const findings = buildExecutiveFindings(
4367
5456
  citationRate,
5457
+ citedQueryCount,
5458
+ totalQueryCount,
4368
5459
  trend,
4369
5460
  citationsTrend,
4370
5461
  trendBaseline,
@@ -4373,6 +5464,68 @@ function buildProjectReport(db, projectName) {
4373
5464
  );
4374
5465
  const periodStart = citationsTrend[0]?.date ?? null;
4375
5466
  const periodEnd = citationsTrend.at(-1)?.date ?? null;
5467
+ const configuredLocations = parseJsonColumn(project.locations, []);
5468
+ const reportLocation = buildLocationMeta(latestRun?.location ?? null, configuredLocations);
5469
+ const providerLocationHandling = reportLocation ? buildProviderLocationHandling(citationScorecard.providers) : [];
5470
+ const executiveSummary = {
5471
+ citationRate,
5472
+ citedQueryCount,
5473
+ totalQueryCount,
5474
+ mentionRate,
5475
+ mentionedQueryCount,
5476
+ trend,
5477
+ queryCount: queryLookup.byId.size,
5478
+ competitorCount: competitorDomains.length,
5479
+ providerCount: citationScorecard.providers.length,
5480
+ gsc: gscSection ? {
5481
+ clicks: gscSection.totalClicks,
5482
+ impressions: gscSection.totalImpressions,
5483
+ ctr: gscSection.ctr,
5484
+ avgPosition: gscSection.avgPosition,
5485
+ periodStart: gscSection.periodStart,
5486
+ periodEnd: gscSection.periodEnd
5487
+ } : null,
5488
+ ga: gaSection ? {
5489
+ sessions: gaSection.totalSessions,
5490
+ users: gaSection.totalUsers,
5491
+ periodStart: gaSection.periodStart,
5492
+ periodEnd: gaSection.periodEnd
5493
+ } : null,
5494
+ findings
5495
+ };
5496
+ const actionPlan = buildReportActionPlan({
5497
+ canonicalDomain: project.canonicalDomain,
5498
+ competitorDomains,
5499
+ citationScorecard,
5500
+ aiSourceOrigin,
5501
+ gsc: gscSection,
5502
+ indexingHealth: indexingHealthSection,
5503
+ contentOpportunities,
5504
+ contentGaps,
5505
+ reportLocation,
5506
+ providerLocationHandling
5507
+ });
5508
+ const clientSummary = buildClientSummary({
5509
+ canonicalDomain: project.canonicalDomain,
5510
+ reportLocation,
5511
+ executiveSummary,
5512
+ citationsTrend,
5513
+ gsc: gscSection,
5514
+ actionPlan
5515
+ });
5516
+ const agencyDiagnostics = buildAgencyDiagnostics({
5517
+ canonicalDomain: project.canonicalDomain,
5518
+ competitorDomains,
5519
+ citationScorecard,
5520
+ aiSourceOrigin,
5521
+ gsc: gscSection,
5522
+ indexingHealth: indexingHealthSection,
5523
+ contentOpportunities,
5524
+ contentGaps,
5525
+ reportLocation,
5526
+ providerLocationHandling,
5527
+ actionPlan
5528
+ });
4376
5529
  return {
4377
5530
  meta: {
4378
5531
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -4384,29 +5537,12 @@ function buildProjectReport(db, projectName) {
4384
5537
  country: project.country,
4385
5538
  language: project.language
4386
5539
  },
5540
+ location: reportLocation,
5541
+ providerLocationHandling,
4387
5542
  periodStart,
4388
5543
  periodEnd
4389
5544
  },
4390
- executiveSummary: {
4391
- citationRate,
4392
- trend,
4393
- queryCount: queryLookup.byId.size,
4394
- competitorCount: competitorDomains.length,
4395
- providerCount: citationScorecard.providers.length,
4396
- gsc: gscSection ? {
4397
- clicks: gscSection.totalClicks,
4398
- impressions: gscSection.totalImpressions,
4399
- ctr: gscSection.ctr,
4400
- avgPosition: gscSection.avgPosition
4401
- } : null,
4402
- ga: gaSection ? {
4403
- sessions: gaSection.totalSessions,
4404
- users: gaSection.totalUsers,
4405
- periodStart: gaSection.periodStart,
4406
- periodEnd: gaSection.periodEnd
4407
- } : null,
4408
- findings
4409
- },
5545
+ executiveSummary,
4410
5546
  citationScorecard,
4411
5547
  competitorLandscape,
4412
5548
  mentionLandscape,
@@ -4419,14 +5555,22 @@ function buildProjectReport(db, projectName) {
4419
5555
  citationsTrend,
4420
5556
  insights: insightList,
4421
5557
  recommendedNextSteps,
5558
+ actionPlan,
5559
+ clientSummary,
5560
+ agencyDiagnostics,
4422
5561
  contentOpportunities,
4423
5562
  contentGaps,
4424
5563
  groundingSources
4425
5564
  };
4426
5565
  }
4427
- function reportFilenameFor(project, generatedAt) {
5566
+ function parseReportAudience(value) {
5567
+ if (value === void 0 || value === "agency") return "agency";
5568
+ if (value === "client") return "client";
5569
+ throw validationError('"audience" must be "agency" or "client"');
5570
+ }
5571
+ function reportFilenameFor(project, generatedAt, audience) {
4428
5572
  const date = generatedAt.slice(0, 10);
4429
- return `canonry-report-${project.name}-${date}.html`;
5573
+ return `canonry-report-${project.name}-${audience}-${date}.html`;
4430
5574
  }
4431
5575
  async function reportRoutes(app) {
4432
5576
  app.get("/projects/:name/report", async (request, reply) => {
@@ -4434,9 +5578,10 @@ async function reportRoutes(app) {
4434
5578
  return reply.send(dto);
4435
5579
  });
4436
5580
  app.get("/projects/:name/report.html", async (request, reply) => {
5581
+ const audience = parseReportAudience(request.query.audience);
4437
5582
  const dto = buildProjectReport(app.db, request.params.name);
4438
- const html = renderReportHtml(dto);
4439
- const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt);
5583
+ const html = renderReportHtml(dto, { audience });
5584
+ const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt, audience);
4440
5585
  reply.header("Content-Type", "text/html; charset=utf-8");
4441
5586
  reply.header("Content-Disposition", `attachment; filename="${filename}"`);
4442
5587
  return reply.send(html);
@@ -5294,6 +6439,12 @@ var locationQueryParameter = {
5294
6439
  description: "Filter by location label. Use an empty value to request locationless results.",
5295
6440
  schema: stringSchema
5296
6441
  };
6442
+ var reportAudienceQueryParameter = {
6443
+ name: "audience",
6444
+ in: "query",
6445
+ description: "HTML report audience mode. Defaults to agency.",
6446
+ schema: { type: "string", enum: ["agency", "client"] }
6447
+ };
5297
6448
  var analyticsWindowParameter = {
5298
6449
  name: "window",
5299
6450
  in: "query",
@@ -7514,9 +8665,9 @@ var routeCatalog = [
7514
8665
  {
7515
8666
  method: "get",
7516
8667
  path: "/api/v1/projects/{name}/report",
7517
- summary: "Aggregated client-facing AEO report",
8668
+ summary: "Aggregated canonical AEO report",
7518
8669
  tags: ["report"],
7519
- description: "Bundles every section the canonry-report HTML output needs (executive summary, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single JSON payload. Backs `canonry report <project>`.",
8670
+ description: "Bundles every section the canonry-report HTML output needs (executive summary, client summary, agency diagnostics, action plan, citation scorecard, competitor landscape \u2014 citation + mention landscapes, AI citation sources, GSC, GA4, social/AI referrals, indexing health, citations trend, insights, and recommended next steps) into a single canonical JSON payload. Backs `canonry report <project>` and MCP report reads.",
7520
8671
  parameters: [nameParameter],
7521
8672
  responses: {
7522
8673
  200: { description: "Report returned." },
@@ -7528,8 +8679,8 @@ var routeCatalog = [
7528
8679
  path: "/api/v1/projects/{name}/report.html",
7529
8680
  summary: "Standalone HTML AEO report",
7530
8681
  tags: ["report"],
7531
- description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
7532
- parameters: [nameParameter],
8682
+ description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer in agency or client mode. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-<audience>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
8683
+ parameters: [nameParameter, reportAudienceQueryParameter],
7533
8684
  responses: {
7534
8685
  200: { description: "HTML report returned." },
7535
8686
  404: { description: "Project not found." }