@ainyc/canonry 4.7.2 → 4.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-VDEMEI64.js";
7
+ } from "./chunk-GAC7BSL6.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-OOADR2Q5.js";
64
+ } from "./chunk-XRDZ26OZ.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,6 +90,9 @@ import {
89
90
  categoryLabel,
90
91
  citationStateToCited,
91
92
  competitorBatchRequestSchema,
93
+ contentActionLabel,
94
+ dedupeReportActions,
95
+ dedupeReportOpportunities,
92
96
  deliveryFailed,
93
97
  determineAnswerMentioned,
94
98
  effectiveDomains,
@@ -114,7 +118,11 @@ import {
114
118
  providerError,
115
119
  queryGenerateRequestSchema,
116
120
  registrableDomain,
121
+ reportActionCategoryLabel,
117
122
  reportActionTone,
123
+ reportConfidenceLabel,
124
+ reportHorizonLabel,
125
+ reportSeverityLabel,
118
126
  resolveConfigSpecQueries,
119
127
  resolveSnapshotRequestQueries,
120
128
  runInProgress,
@@ -129,7 +137,7 @@ import {
129
137
  visibilityStateFromAnswerMentioned,
130
138
  windowCutoff,
131
139
  wordpressEnvSchema
132
- } from "./chunk-XAW66QUX.js";
140
+ } from "./chunk-GPJ3GLOE.js";
133
141
 
134
142
  // src/telemetry.ts
135
143
  import crypto from "crypto";
@@ -2622,12 +2630,43 @@ function formatLandingPageHtml(raw) {
2622
2630
  function formatDate(iso) {
2623
2631
  if (!iso) return "\u2014";
2624
2632
  try {
2625
- const d = new Date(iso);
2626
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
2633
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
2634
+ const options = { month: "short", day: "numeric", year: "numeric" };
2635
+ 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);
2636
+ if (Number.isNaN(d.getTime())) return iso;
2637
+ return d.toLocaleDateString("en-US", dateOnly ? { ...options, timeZone: "UTC" } : options);
2627
2638
  } catch {
2628
2639
  return iso;
2629
2640
  }
2630
2641
  }
2642
+ function formatDateRange(start, end) {
2643
+ if (!start && !end) return "";
2644
+ if (start && end) return `${formatDate(start)} \u2192 ${formatDate(end)}`;
2645
+ return formatDate(start || end);
2646
+ }
2647
+ function gscDateRange(report) {
2648
+ const summary = report.executiveSummary.gsc;
2649
+ const gsc = report.gsc;
2650
+ const start = summary?.periodStart || gsc?.periodStart || gsc?.trend[0]?.date || "";
2651
+ const end = summary?.periodEnd || gsc?.periodEnd || gsc?.trend.at(-1)?.date || "";
2652
+ return formatDateRange(start, end);
2653
+ }
2654
+ function pluralize(count, singular, plural = `${singular}s`) {
2655
+ return count === 1 ? singular : plural;
2656
+ }
2657
+ function compactInlineList(items, limit = 3) {
2658
+ const visible = items.slice(0, limit);
2659
+ const more = items.length - visible.length;
2660
+ return `${visible.join(", ")}${more > 0 ? `, +${more} more` : ""}`;
2661
+ }
2662
+ function renderProofChips(items, limit = 3) {
2663
+ if (items.length === 0) return "";
2664
+ const visible = items.slice(0, limit);
2665
+ const more = items.length - visible.length;
2666
+ const chips = visible.map((item) => `<span class="proof-chip">${escapeHtml(item)}</span>`);
2667
+ if (more > 0) chips.push(`<span class="proof-chip">+${more} more</span>`);
2668
+ return `<div class="proof-chips">${chips.join("")}</div>`;
2669
+ }
2631
2670
  function pressureTone(label) {
2632
2671
  if (label === "High") return "negative";
2633
2672
  if (label === "Moderate") return "caution";
@@ -2674,7 +2713,7 @@ body {
2674
2713
  font-size: 32px;
2675
2714
  font-weight: 700;
2676
2715
  margin: 0 0 8px;
2677
- letter-spacing: -0.02em;
2716
+ letter-spacing: 0;
2678
2717
  }
2679
2718
  .header .subtitle {
2680
2719
  color: ${COLORS.textMuted};
@@ -2682,7 +2721,7 @@ body {
2682
2721
  }
2683
2722
  .eyebrow {
2684
2723
  text-transform: uppercase;
2685
- letter-spacing: 0.08em;
2724
+ letter-spacing: 0;
2686
2725
  font-size: 10px;
2687
2726
  color: ${COLORS.textFaint};
2688
2727
  font-weight: 600;
@@ -2695,11 +2734,75 @@ section.report-section h2 {
2695
2734
  font-size: 22px;
2696
2735
  font-weight: 700;
2697
2736
  margin: 0 0 24px;
2698
- letter-spacing: -0.01em;
2737
+ letter-spacing: 0;
2699
2738
  }
2700
2739
  section.report-section .section-intro {
2701
2740
  color: ${COLORS.textMuted};
2702
2741
  margin-bottom: 24px;
2742
+ max-width: 760px;
2743
+ }
2744
+ .executive-hero {
2745
+ display: grid;
2746
+ grid-template-columns: minmax(0, 1.35fr) minmax(240px, 0.65fr);
2747
+ gap: 16px;
2748
+ margin-bottom: 16px;
2749
+ }
2750
+ .headline-card {
2751
+ background: #111827;
2752
+ border: 1px solid ${COLORS.border};
2753
+ border-radius: 8px;
2754
+ padding: 28px;
2755
+ min-height: 220px;
2756
+ display: flex;
2757
+ flex-direction: column;
2758
+ justify-content: space-between;
2759
+ }
2760
+ .headline-card .hero-kicker {
2761
+ color: ${COLORS.textMuted};
2762
+ font-size: 12px;
2763
+ font-weight: 600;
2764
+ text-transform: uppercase;
2765
+ letter-spacing: 0;
2766
+ }
2767
+ .headline-card .hero-title {
2768
+ font-size: 44px;
2769
+ line-height: 1.05;
2770
+ font-weight: 800;
2771
+ letter-spacing: 0;
2772
+ margin: 18px 0;
2773
+ }
2774
+ .headline-card .hero-subtitle {
2775
+ color: ${COLORS.textMuted};
2776
+ font-size: 15px;
2777
+ max-width: 620px;
2778
+ }
2779
+ .hero-proof-grid {
2780
+ display: grid;
2781
+ gap: 12px;
2782
+ }
2783
+ .hero-proof {
2784
+ background: ${COLORS.surface};
2785
+ border: 1px solid ${COLORS.border};
2786
+ border-radius: 8px;
2787
+ padding: 18px;
2788
+ }
2789
+ .hero-proof .mini-label {
2790
+ color: ${COLORS.textFaint};
2791
+ font-size: 10px;
2792
+ font-weight: 600;
2793
+ text-transform: uppercase;
2794
+ letter-spacing: 0;
2795
+ margin-bottom: 8px;
2796
+ }
2797
+ .hero-proof .mini-value {
2798
+ font-size: 30px;
2799
+ line-height: 1;
2800
+ font-weight: 800;
2801
+ }
2802
+ .hero-proof .mini-copy {
2803
+ color: ${COLORS.textMuted};
2804
+ font-size: 12px;
2805
+ margin-top: 8px;
2703
2806
  }
2704
2807
  .metric-grid {
2705
2808
  display: grid;
@@ -2714,7 +2817,7 @@ section.report-section .section-intro {
2714
2817
  }
2715
2818
  .metric .label {
2716
2819
  text-transform: uppercase;
2717
- letter-spacing: 0.08em;
2820
+ letter-spacing: 0;
2718
2821
  font-size: 10px;
2719
2822
  color: ${COLORS.textFaint};
2720
2823
  font-weight: 600;
@@ -2723,7 +2826,7 @@ section.report-section .section-intro {
2723
2826
  .metric .value {
2724
2827
  font-size: 28px;
2725
2828
  font-weight: 700;
2726
- letter-spacing: -0.02em;
2829
+ letter-spacing: 0;
2727
2830
  }
2728
2831
  .metric .delta {
2729
2832
  font-size: 12px;
@@ -2748,10 +2851,46 @@ section.report-section .section-intro {
2748
2851
  .finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
2749
2852
  .finding strong { display: block; margin-bottom: 4px; }
2750
2853
  .finding span { color: ${COLORS.textMuted}; font-size: 13px; }
2751
- .location-card { margin-top: 16px; }
2752
- .location-card .location-line { margin: 0 0 12px; font-size: 13px; color: ${COLORS.text}; }
2753
- .location-card .location-line strong { color: ${COLORS.text}; }
2754
- .location-card .location-line .cell-pending { font-size: 12px; }
2854
+ .market-scope-card { margin-top: 16px; }
2855
+ .market-scope-grid {
2856
+ display: grid;
2857
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2858
+ gap: 12px;
2859
+ }
2860
+ .scope-tile {
2861
+ background: #09090b;
2862
+ border: 1px solid ${COLORS.border};
2863
+ border-radius: 8px;
2864
+ padding: 14px;
2865
+ }
2866
+ .scope-tile .scope-label {
2867
+ color: ${COLORS.textFaint};
2868
+ font-size: 10px;
2869
+ font-weight: 600;
2870
+ text-transform: uppercase;
2871
+ letter-spacing: 0;
2872
+ margin-bottom: 8px;
2873
+ }
2874
+ .scope-tile .scope-value {
2875
+ font-size: 18px;
2876
+ line-height: 1.2;
2877
+ font-weight: 700;
2878
+ }
2879
+ .scope-tile .scope-copy {
2880
+ color: ${COLORS.textMuted};
2881
+ font-size: 12px;
2882
+ margin-top: 8px;
2883
+ }
2884
+ .scope-warning {
2885
+ margin-top: 12px;
2886
+ border: 1px solid ${COLORS.caution}55;
2887
+ background: ${COLORS.caution}14;
2888
+ border-radius: 8px;
2889
+ padding: 12px 14px;
2890
+ color: ${COLORS.textMuted};
2891
+ font-size: 13px;
2892
+ }
2893
+ .scope-warning strong { color: ${COLORS.text}; display: block; margin-bottom: 4px; }
2755
2894
  .source-origin-headline { margin: 0 0 12px; font-size: 14px; color: ${COLORS.text}; }
2756
2895
  .source-origin-headline strong { color: ${COLORS.text}; }
2757
2896
  .source-bars { display: flex; flex-direction: column; gap: 6px; }
@@ -2773,18 +2912,24 @@ table.report-table th, table.report-table td {
2773
2912
  padding: 10px 12px;
2774
2913
  border-bottom: 1px solid ${COLORS.border};
2775
2914
  vertical-align: top;
2776
- overflow-wrap: anywhere;
2777
- word-break: break-word;
2915
+ overflow-wrap: break-word;
2916
+ hyphens: auto;
2778
2917
  }
2779
2918
  table.report-table th {
2780
2919
  font-weight: 600;
2781
2920
  color: ${COLORS.textMuted};
2782
2921
  text-transform: uppercase;
2783
- letter-spacing: 0.06em;
2922
+ letter-spacing: 0;
2784
2923
  font-size: 10px;
2785
2924
  }
2786
2925
  table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
2787
2926
  table.report-table td.page-cell { max-width: 0; }
2927
+ table.insights-table { table-layout: fixed; }
2928
+ table.insights-table th.col-severity, table.insights-table td.col-severity { width: 96px; }
2929
+ table.insights-table th.col-query, table.insights-table td.col-query { width: 18%; }
2930
+ table.insights-table th.col-provider, table.insights-table td.col-provider { width: 88px; }
2931
+ table.insights-table th.col-title, table.insights-table td.col-title { width: 28%; }
2932
+ table.insights-table th.col-recommendation, table.insights-table td.col-recommendation { width: auto; }
2788
2933
  table.report-table td.page-cell .page-path {
2789
2934
  display: block;
2790
2935
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
@@ -2877,7 +3022,7 @@ table.report-table td .badge {
2877
3022
  .step .horizon {
2878
3023
  text-transform: uppercase;
2879
3024
  font-size: 10px;
2880
- letter-spacing: 0.08em;
3025
+ letter-spacing: 0;
2881
3026
  color: ${COLORS.textFaint};
2882
3027
  font-weight: 600;
2883
3028
  }
@@ -2892,20 +3037,40 @@ table.report-table td .badge {
2892
3037
  background: ${COLORS.surface};
2893
3038
  border: 1px solid ${COLORS.border};
2894
3039
  border-radius: 8px;
2895
- padding: 18px 20px;
3040
+ padding: 18px;
3041
+ display: flex;
3042
+ flex-direction: column;
3043
+ gap: 12px;
3044
+ }
3045
+ .action-card .action-head {
3046
+ display: grid;
3047
+ grid-template-columns: 42px 1fr;
3048
+ gap: 12px;
3049
+ align-items: start;
3050
+ }
3051
+ .action-card .action-rank {
3052
+ border: 1px solid ${COLORS.border};
3053
+ border-radius: 8px;
3054
+ height: 42px;
3055
+ display: flex;
3056
+ align-items: center;
3057
+ justify-content: center;
3058
+ font-size: 16px;
3059
+ font-weight: 800;
3060
+ color: ${COLORS.text};
3061
+ background: #09090b;
2896
3062
  }
2897
3063
  .action-card .action-meta {
2898
3064
  display: flex;
2899
3065
  flex-wrap: wrap;
2900
3066
  gap: 8px;
2901
- margin-bottom: 10px;
2902
3067
  }
2903
3068
  .action-card h3 {
2904
3069
  font-size: 16px;
2905
- margin: 0 0 8px;
3070
+ margin: 8px 0 0;
2906
3071
  }
2907
3072
  .action-card p {
2908
- margin: 0 0 12px;
3073
+ margin: 0;
2909
3074
  color: ${COLORS.textMuted};
2910
3075
  }
2911
3076
  .action-card ul {
@@ -2915,6 +3080,28 @@ table.report-table td .badge {
2915
3080
  font-size: 13px;
2916
3081
  }
2917
3082
  .action-card li { margin: 4px 0; }
3083
+ .proof-chips {
3084
+ display: flex;
3085
+ flex-wrap: wrap;
3086
+ gap: 8px;
3087
+ }
3088
+ .proof-chip {
3089
+ border: 1px solid ${COLORS.border};
3090
+ border-radius: 8px;
3091
+ padding: 6px 8px;
3092
+ color: ${COLORS.textMuted};
3093
+ font-size: 12px;
3094
+ background: #09090b;
3095
+ }
3096
+ .action-details {
3097
+ color: ${COLORS.textMuted};
3098
+ font-size: 12px;
3099
+ }
3100
+ .action-details summary {
3101
+ cursor: pointer;
3102
+ color: ${COLORS.text};
3103
+ font-weight: 600;
3104
+ }
2918
3105
  .action-card .success-metric {
2919
3106
  color: ${COLORS.text};
2920
3107
  font-size: 13px;
@@ -2950,10 +3137,44 @@ table.report-table td .badge {
2950
3137
  .diagnostic-card h3 { font-size: 14px; margin: 0 0 6px; }
2951
3138
  .diagnostic-card p { margin: 0 0 8px; color: ${COLORS.textMuted}; font-size: 13px; }
2952
3139
  .diagnostic-card ul { margin: 0; padding-left: 16px; color: ${COLORS.textMuted}; font-size: 12px; }
3140
+ .diagnostic-card .proof-chips { margin-top: 10px; }
2953
3141
  .diagnostic-card.tone-positive { border-left-color: ${COLORS.positive}; }
2954
3142
  .diagnostic-card.tone-caution { border-left-color: ${COLORS.caution}; }
2955
3143
  .diagnostic-card.tone-negative { border-left-color: ${COLORS.negative}; }
2956
3144
  .diagnostic-card.tone-neutral { border-left-color: ${COLORS.neutral}; }
3145
+ .opportunity-grid {
3146
+ display: grid;
3147
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
3148
+ gap: 12px;
3149
+ margin-bottom: 16px;
3150
+ }
3151
+ .opportunity-card {
3152
+ background: ${COLORS.surface};
3153
+ border: 1px solid ${COLORS.border};
3154
+ border-radius: 8px;
3155
+ padding: 16px;
3156
+ }
3157
+ .opportunity-card .opportunity-score {
3158
+ font-size: 32px;
3159
+ line-height: 1;
3160
+ font-weight: 800;
3161
+ margin-bottom: 10px;
3162
+ }
3163
+ .opportunity-card .opportunity-score-suffix {
3164
+ font-size: 14px;
3165
+ font-weight: 600;
3166
+ color: ${COLORS.textFaint};
3167
+ margin-left: 4px;
3168
+ }
3169
+ .opportunity-card h3 {
3170
+ font-size: 14px;
3171
+ margin: 0 0 8px;
3172
+ }
3173
+ .opportunity-card p {
3174
+ color: ${COLORS.textMuted};
3175
+ font-size: 12px;
3176
+ margin: 0;
3177
+ }
2957
3178
  .footer {
2958
3179
  margin-top: 96px;
2959
3180
  padding-top: 24px;
@@ -2962,6 +3183,14 @@ table.report-table td .badge {
2962
3183
  color: ${COLORS.textFaint};
2963
3184
  font-size: 12px;
2964
3185
  }
3186
+ @media (max-width: 760px) {
3187
+ .container { padding: 32px 16px 72px; }
3188
+ .executive-hero { grid-template-columns: 1fr; }
3189
+ .headline-card .hero-title { font-size: 34px; }
3190
+ .source-bar-row { grid-template-columns: 1fr; gap: 6px; }
3191
+ .source-bar-value { text-align: left; }
3192
+ .chart-grid { grid-template-columns: 1fr; }
3193
+ }
2965
3194
  @media print {
2966
3195
  body { background: white; color: black; }
2967
3196
  section.report-section { break-inside: avoid; }
@@ -2984,43 +3213,43 @@ function locationDisplay(location) {
2984
3213
  return place ? `${location.label} (${place})` : location.label;
2985
3214
  }
2986
3215
  function renderHeaderLocationFragment(location) {
2987
- if (!location) return " \xB7 No location set";
2988
- return ` \xB7 Location: ${escapeHtml(locationDisplay(location))}`;
3216
+ if (!location) return " \xB7 No market set";
3217
+ return ` \xB7 Market: ${escapeHtml(locationDisplay(location))}`;
2989
3218
  }
2990
3219
  function renderLocationCard(report) {
2991
3220
  const location = report.meta.location;
2992
3221
  const handling = report.meta.providerLocationHandling;
2993
3222
  if (!location && handling.length === 0) return "";
2994
- const treatmentTone = {
2995
- "request-param": "positive",
2996
- prompt: "positive",
2997
- "browser-geo": "caution",
2998
- ignored: "negative"
2999
- };
3000
- const treatmentLabel = {
3001
- "request-param": "Request parameter",
3002
- prompt: "Prompt-injected",
3003
- "browser-geo": "Browser geo",
3004
- ignored: "Ignored"
3005
- };
3006
- const locationLine = location ? `<p class="location-line"><strong>Location for this run:</strong> ${escapeHtml(locationDisplay(location))}${location.otherConfiguredLabels.length > 0 ? ` <span class="cell-pending">\u2014 other configured locations (${location.otherConfiguredLabels.map(escapeHtml).join(", ")}) need their own sweep to compare</span>` : ""}</p>` : `<p class="location-line"><strong>Location for this run:</strong> none \u2014 providers received the queries verbatim with no geographic hint.</p>`;
3007
- const handlingRows = handling.length > 0 ? handling.map((h) => {
3008
- const tone = treatmentTone[h.treatment] ?? "neutral";
3009
- const label = treatmentLabel[h.treatment] ?? h.treatment;
3010
- return `<tr>
3011
- <td>${escapeHtml(h.provider)}</td>
3012
- <td><span class="badge tone-${tone}">${escapeHtml(label)}</span></td>
3013
- <td>${escapeHtml(h.description)}</td>
3014
- </tr>`;
3015
- }).join("") : "";
3016
- const handlingTable = handlingRows ? `<table class="report-table">
3017
- <thead><tr><th>Provider</th><th>Treatment</th><th>How the location reached the model</th></tr></thead>
3018
- <tbody>${handlingRows}</tbody>
3019
- </table>` : "";
3020
- return `<div class="chart-card location-card">
3021
- <h3>Location handling</h3>
3022
- ${locationLine}
3023
- ${handlingTable}
3223
+ const otherLocations = location?.otherConfiguredLabels ?? [];
3224
+ const weakLocationProviders = handling.filter((h) => h.treatment === "ignored" || h.treatment === "browser-geo").map((h) => h.provider);
3225
+ const marketValue = location ? locationDisplay(location) : "No market set";
3226
+ const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : "None";
3227
+ const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching check before cross-market recommendations.` : "Single-market report; findings can be read as the current market view." : "No geographic hint was attached to this check; read findings as default-market or national results.";
3228
+ 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.";
3229
+ const warning = weakLocationProviders.length > 0 ? `<div class="scope-warning">
3230
+ <strong>Location handling needs review</strong>
3231
+ ${escapeHtml(compactInlineList(weakLocationProviders, 4))} used weak or indirect market handling. Treat provider-level differences cautiously.
3232
+ </div>` : "";
3233
+ return `<div class="chart-card market-scope-card">
3234
+ <h3>Market Scope</h3>
3235
+ <div class="market-scope-grid">
3236
+ <div class="scope-tile">
3237
+ <div class="scope-label">Current check</div>
3238
+ <div class="scope-value">${escapeHtml(marketValue)}</div>
3239
+ <div class="scope-copy">All findings below are scoped to this run.</div>
3240
+ </div>
3241
+ <div class="scope-tile">
3242
+ <div class="scope-label">Not included</div>
3243
+ <div class="scope-value">${escapeHtml(notIncluded)}</div>
3244
+ <div class="scope-copy">${escapeHtml(interpretation)}</div>
3245
+ </div>
3246
+ <div class="scope-tile">
3247
+ <div class="scope-label">Provider context</div>
3248
+ <div class="scope-value">${handling.length > 0 ? formatNumber(handling.length) : "\u2014"}</div>
3249
+ <div class="scope-copy">${escapeHtml(providerCopy)}</div>
3250
+ </div>
3251
+ </div>
3252
+ ${warning}
3024
3253
  </div>`;
3025
3254
  }
3026
3255
  function renderExecutiveSummary(report) {
@@ -3030,6 +3259,36 @@ function renderExecutiveSummary(report) {
3030
3259
  const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
3031
3260
  const citedFragment = s.totalQueryCount > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
3032
3261
  const mentionedFragment = s.totalQueryCount > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
3262
+ const headlineTitle = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
3263
+ const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a check to populate the first citation and mention baseline.";
3264
+ const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
3265
+ const actionCount = dedupeReportActions(report, priorityActions).length;
3266
+ const heroHtml = `<div class="executive-hero">
3267
+ <div class="headline-card">
3268
+ <div>
3269
+ <div class="hero-kicker">Latest AI visibility check</div>
3270
+ <div class="hero-title">${escapeHtml(headlineTitle)}</div>
3271
+ </div>
3272
+ <div class="hero-subtitle">${escapeHtml(headlineSubtitle)}</div>
3273
+ </div>
3274
+ <div class="hero-proof-grid">
3275
+ <div class="hero-proof">
3276
+ <div class="mini-label">Citation trend</div>
3277
+ <div class="mini-value tone-${trendTone}">${escapeHtml(trendLabel)}</div>
3278
+ <div class="mini-copy">${escapeHtml(citedFragment)}</div>
3279
+ </div>
3280
+ <div class="hero-proof">
3281
+ <div class="mini-label">Mention coverage</div>
3282
+ <div class="mini-value">${s.mentionRate}%</div>
3283
+ <div class="mini-copy">${escapeHtml(mentionedFragment)}</div>
3284
+ </div>
3285
+ <div class="hero-proof">
3286
+ <div class="mini-label">Prioritized actions</div>
3287
+ <div class="mini-value">${formatNumber(actionCount)}</div>
3288
+ <div class="mini-copy">Sorted for agency follow-up.</div>
3289
+ </div>
3290
+ </div>
3291
+ </div>`;
3033
3292
  const metrics = [
3034
3293
  {
3035
3294
  label: "Citation rate",
@@ -3048,10 +3307,11 @@ function renderExecutiveSummary(report) {
3048
3307
  }
3049
3308
  ];
3050
3309
  if (s.gsc) {
3310
+ const dateRange = gscDateRange(report);
3051
3311
  metrics.push({
3052
3312
  label: "GSC clicks",
3053
3313
  value: formatNumber(s.gsc.clicks),
3054
- delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
3314
+ delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR${dateRange ? ` \xB7 ${escapeHtml(dateRange)}` : ""}`
3055
3315
  });
3056
3316
  }
3057
3317
  if (s.ga) {
@@ -3079,9 +3339,110 @@ function renderExecutiveSummary(report) {
3079
3339
  id: "executive-summary",
3080
3340
  eyebrow: "Section 1",
3081
3341
  title: "Executive Summary",
3082
- intro: "Two independent signals: Citation rate = share of tracked queries where your domain appeared in the source list the AI used. Mention rate = share of tracked queries where your brand or domain appeared in the answer text itself. A model can mention you without citing your domain, or cite your domain without naming you in the prose. Both are computed per-query so they stay comparable when provider count changes."
3342
+ intro: "Citation = source list. Mention = answer text. They are independent signals."
3083
3343
  },
3084
- metricsHtml + findingsHtml + locationHtml
3344
+ heroHtml + metricsHtml + findingsHtml + locationHtml
3345
+ );
3346
+ }
3347
+ function deltaToneClass(direction) {
3348
+ if (direction === "up") return "tone-positive";
3349
+ if (direction === "down") return "tone-negative";
3350
+ return "";
3351
+ }
3352
+ function deltaArrow(direction) {
3353
+ if (direction === "up") return "\u2191";
3354
+ if (direction === "down") return "\u2193";
3355
+ return "\u2192";
3356
+ }
3357
+ function renderRateDeltaTile(label, delta, unit) {
3358
+ if (!delta) {
3359
+ return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">\u2014</div><div class="delta">No prior data</div></div>`;
3360
+ }
3361
+ const valueSuffix = unit === "%" ? "%" : "";
3362
+ const deltaSign = delta.deltaAbs > 0 ? "+" : "";
3363
+ const deltaText = `${deltaSign}${delta.deltaAbs.toFixed(unit === "%" ? 1 : 0)}${valueSuffix} vs ${delta.prior}${valueSuffix}`;
3364
+ return `<div class="metric">
3365
+ <div class="label">${escapeHtml(label)}</div>
3366
+ <div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
3367
+ <div class="delta">${deltaText}</div>
3368
+ </div>`;
3369
+ }
3370
+ function renderTrafficDeltaTile(label, delta, countLabel) {
3371
+ if (!delta) {
3372
+ return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">\u2014</div><div class="delta">Not enough trend data</div></div>`;
3373
+ }
3374
+ const deltaSign = delta.deltaAbs > 0 ? "+" : "";
3375
+ const deltaText = `${deltaSign}${formatNumber(delta.deltaAbs)} ${countLabel} vs prior ${WHATS_CHANGED_PERIOD_DAYS} days`;
3376
+ return `<div class="metric">
3377
+ <div class="label">${escapeHtml(label)}</div>
3378
+ <div class="value ${deltaToneClass(delta.direction)}">${formatNumber(delta.current)} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
3379
+ <div class="delta">${deltaText}</div>
3380
+ </div>`;
3381
+ }
3382
+ var WHATS_CHANGED_PERIOD_DAYS = 14;
3383
+ function renderProviderMovements(movements) {
3384
+ const meaningful = movements.filter((m) => m.direction !== "flat");
3385
+ if (meaningful.length === 0) return "";
3386
+ const rows = meaningful.map((m) => {
3387
+ const sign = m.deltaAbs > 0 ? "+" : "";
3388
+ return `<tr>
3389
+ <td>${escapeHtml(m.provider)}</td>
3390
+ <td class="numeric">${m.prior}%</td>
3391
+ <td class="numeric">${m.current}%</td>
3392
+ <td class="numeric ${deltaToneClass(m.direction)}">${sign}${m.deltaAbs.toFixed(1)}% ${deltaArrow(m.direction)}</td>
3393
+ </tr>`;
3394
+ }).join("");
3395
+ return `<div class="chart-card"><h3>AI engine movements</h3>
3396
+ <table class="report-table">
3397
+ <thead><tr><th>Engine</th><th class="numeric">Prior</th><th class="numeric">Current</th><th class="numeric">Change</th></tr></thead>
3398
+ <tbody>${rows}</tbody>
3399
+ </table>
3400
+ </div>`;
3401
+ }
3402
+ function renderWinsLosses(insights2, heading, emptyMessage) {
3403
+ if (insights2.length === 0) {
3404
+ return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
3405
+ <p class="section-intro">${escapeHtml(emptyMessage)}</p>
3406
+ </div>`;
3407
+ }
3408
+ const rows = insights2.map((i) => {
3409
+ const tone = severityTone(i.severity);
3410
+ const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">\xD7 ${i.instanceCount}</span>` : "";
3411
+ return `<tr>
3412
+ <td><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
3413
+ <td>${escapeHtml(i.title)}${countChip}</td>
3414
+ <td>${escapeHtml(i.query)}</td>
3415
+ <td>${escapeHtml(i.provider)}</td>
3416
+ </tr>`;
3417
+ }).join("");
3418
+ return `<div class="chart-card"><h3>${escapeHtml(heading)}</h3>
3419
+ <table class="report-table">
3420
+ <thead><tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th></tr></thead>
3421
+ <tbody>${rows}</tbody>
3422
+ </table>
3423
+ </div>`;
3424
+ }
3425
+ function renderWhatsChanged(report) {
3426
+ const w = report.whatsChanged;
3427
+ if (!w.enoughHistory && !w.gscClicksDelta && !w.aiReferralsDelta && w.wins.length === 0 && w.regressions.length === 0) {
3428
+ return section(
3429
+ { id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
3430
+ renderEmpty("Trends will appear after a few more checks.")
3431
+ );
3432
+ }
3433
+ const rateTiles = `<div class="metric-grid">
3434
+ ${renderRateDeltaTile("Citation rate", w.citationRate, "%")}
3435
+ ${renderRateDeltaTile("Mention rate", w.mentionRate, "%")}
3436
+ ${renderRateDeltaTile("Cited queries", w.citedQueryCount, "count")}
3437
+ ${renderTrafficDeltaTile("GSC clicks", w.gscClicksDelta, "clicks")}
3438
+ ${renderTrafficDeltaTile("AI referral sessions", w.aiReferralsDelta, "sessions")}
3439
+ </div>`;
3440
+ const movements = renderProviderMovements(w.providerMovements);
3441
+ const wins = renderWinsLosses(w.wins, "Wins", "No new gains in the latest check.");
3442
+ const regressions = renderWinsLosses(w.regressions, "Regressions", "No new regressions in the latest check.");
3443
+ return section(
3444
+ { id: "whats-changed", eyebrow: "Section 2", title: "What's Changed", intro: w.headline },
3445
+ `${rateTiles}${movements}${wins}${regressions}`
3085
3446
  );
3086
3447
  }
3087
3448
  function renderProviderBars(rates) {
@@ -3112,7 +3473,7 @@ function renderProviderBars(rates) {
3112
3473
  }
3113
3474
  function renderCitationMatrix(scorecard) {
3114
3475
  if (scorecard.queries.length === 0 || scorecard.providers.length === 0) {
3115
- return renderEmpty("Run a visibility sweep to populate the citation matrix.");
3476
+ return renderEmpty("Run a check to populate the citation matrix.");
3116
3477
  }
3117
3478
  const headers = scorecard.providers.map((p) => `<th>${escapeHtml(p)}</th>`).join("");
3118
3479
  const rows = scorecard.queries.map((q, qi) => {
@@ -3127,7 +3488,7 @@ function renderCitationMatrix(scorecard) {
3127
3488
  }).join("");
3128
3489
  return `<tr><td>${escapeHtml(q)}</td>${cells}</tr>`;
3129
3490
  }).join("");
3130
- const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">Each cell shows two flags \u2014 <span class="cell-cited">C</span>/<span class="cell-not-cited">c</span> = cited / not cited (your domain in the source list), <span class="cell-cited">M</span>/<span class="cell-not-cited">m</span> = mentioned / not mentioned (your brand in the answer text), <span class="cell-pending">\u2013</span> = no data.</p>';
3491
+ 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>';
3131
3492
  return `${legend}<table class="report-table">
3132
3493
  <thead><tr><th>Query</th>${headers}</tr></thead>
3133
3494
  <tbody>${rows}</tbody>
@@ -3139,7 +3500,7 @@ function renderCitationScorecard(report) {
3139
3500
  ${renderCitationMatrix(report.citationScorecard)}
3140
3501
  `;
3141
3502
  return section(
3142
- { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per (query \xD7 provider) view of both signals \u2014 citations (your domain in the source list) and mentions (your brand in the answer text) \u2014 for every tracked query in the latest sweep." },
3503
+ { id: "citation-scorecard", eyebrow: "Section 3", title: "Citation Scorecard", intro: "Per-engine citation and mention coverage from the latest check." },
3143
3504
  body
3144
3505
  );
3145
3506
  }
@@ -3187,8 +3548,8 @@ function renderCompetitorLandscape(report) {
3187
3548
  const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0;
3188
3549
  if (noCitationData && noMentionData) {
3189
3550
  return section(
3190
- { id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape" },
3191
- renderEmpty("No competitor data yet. Add competitors and run a visibility sweep.")
3551
+ { id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
3552
+ renderEmpty("No competitor data yet. Add competitors and run a check.")
3192
3553
  );
3193
3554
  }
3194
3555
  const mentionByDomain = new Map(mentionLandscape.competitors.map((m) => [m.domain, m]));
@@ -3219,9 +3580,9 @@ function renderCompetitorLandscape(report) {
3219
3580
  return section(
3220
3581
  {
3221
3582
  id: "competitor-landscape",
3222
- eyebrow: "Section 3",
3583
+ eyebrow: "Section 4",
3223
3584
  title: "Competitor Landscape",
3224
- intro: "Where tracked competitors appear in AI answers compared to your domain \u2014 both in source citations and in the answer text itself."
3585
+ intro: "Who AI engines cite and mention instead of the client."
3225
3586
  },
3226
3587
  `${charts}${table}`
3227
3588
  );
@@ -3262,12 +3623,32 @@ function renderCategoryBars(buckets) {
3262
3623
  <div class="source-bars">${rows}</div>
3263
3624
  </div>`;
3264
3625
  }
3626
+ function renderShareBars(heading, rows, countLabel) {
3627
+ const visibleRows = rows.filter((r) => r.count > 0 || r.sharePct > 0);
3628
+ if (visibleRows.length === 0) return "";
3629
+ const bars = visibleRows.map((r, index) => {
3630
+ const pct = Math.max(0, Math.min(100, r.sharePct));
3631
+ const color = r.color ?? COLORS.series[index % COLORS.series.length];
3632
+ return `
3633
+ <div class="source-bar-row">
3634
+ <div class="source-bar-label">${escapeHtml(r.label)}</div>
3635
+ <div class="source-bar-track">
3636
+ <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
3637
+ </div>
3638
+ <div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml(countLabel)} \xB7 ${r.sharePct}%</span></div>
3639
+ </div>`;
3640
+ }).join("");
3641
+ return `<div class="chart-card">
3642
+ <h3>${escapeHtml(heading)}</h3>
3643
+ <div class="source-bars">${bars}</div>
3644
+ </div>`;
3645
+ }
3265
3646
  function renderAiSourceOrigin(report) {
3266
3647
  const origin = report.aiSourceOrigin;
3267
3648
  if (origin.categories.length === 0 && origin.topDomains.length === 0) {
3268
3649
  return section(
3269
- { id: "ai-source-origin", eyebrow: "Section 4", title: "AI Citation Sources" },
3270
- renderEmpty("No source data yet. Run a visibility sweep first.")
3650
+ { id: "ai-source-origin", eyebrow: "Section 5", title: "AI Citation Sources" },
3651
+ renderEmpty("No source data yet. Run a check first.")
3271
3652
  );
3272
3653
  }
3273
3654
  const competitorBucket = origin.categories.find((c) => c.category === "competitor");
@@ -3287,9 +3668,9 @@ function renderAiSourceOrigin(report) {
3287
3668
  return section(
3288
3669
  {
3289
3670
  id: "ai-source-origin",
3290
- eyebrow: "Section 4",
3671
+ eyebrow: "Section 5",
3291
3672
  title: "AI Citation Sources",
3292
- intro: "Every external website AI engines cited as a source for your tracked queries in the latest sweep, ranked by citation count. Tracked competitors are pulled into their own bucket so you can see how much of the AI\u2019s answer came from rivals; the remaining buckets cover directories, forums, news, and other site types. Your own domains are excluded."
3673
+ intro: "External domains AI engines cited most in the latest check."
3293
3674
  },
3294
3675
  `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
3295
3676
  );
@@ -3330,7 +3711,7 @@ function renderGsc(report) {
3330
3711
  const gsc = report.gsc;
3331
3712
  if (!gsc) {
3332
3713
  return section(
3333
- { id: "gsc", eyebrow: "Section 5", title: "GSC Performance" },
3714
+ { id: "gsc", eyebrow: "Section 6", title: "GSC Performance" },
3334
3715
  renderEmpty("Connect Google Search Console to populate this section.")
3335
3716
  );
3336
3717
  }
@@ -3343,13 +3724,16 @@ function renderGsc(report) {
3343
3724
  <td class="numeric">${q.avgPosition.toFixed(1)}</td>
3344
3725
  <td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
3345
3726
  </tr>`).join("");
3346
- const breakdownRows = gsc.categoryBreakdown.map((c) => `
3347
- <tr>
3348
- <td>${escapeHtml(c.category)}</td>
3349
- <td class="numeric">${formatNumber(c.clicks)}</td>
3350
- <td class="numeric">${formatNumber(c.impressions)}</td>
3351
- <td class="numeric">${c.sharePct}%</td>
3352
- </tr>`).join("");
3727
+ const categoryBars = renderShareBars(
3728
+ "Search demand by intent",
3729
+ gsc.categoryBreakdown.map((c, index) => ({
3730
+ label: c.category,
3731
+ count: c.clicks,
3732
+ sharePct: c.sharePct,
3733
+ color: COLORS.series[index % COLORS.series.length]
3734
+ })),
3735
+ "clicks"
3736
+ );
3353
3737
  const trendChart = renderLineChart(
3354
3738
  gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
3355
3739
  COLORS.accent,
@@ -3358,18 +3742,19 @@ function renderGsc(report) {
3358
3742
  const crossoverBlocks = [];
3359
3743
  if (gsc.trackedButNoGsc.length > 0) {
3360
3744
  crossoverBlocks.push(`<div class="chart-card"><h3>AEO queries without search demand</h3>
3361
- <p class="section-intro">Tracked AEO queries with no GSC impressions in this window \u2014 review whether they represent real search demand.</p>
3362
- <ul>${gsc.trackedButNoGsc.map((q) => `<li>${escapeHtml(q)}</li>`).join("")}</ul>
3745
+ <p class="section-intro">Review whether these still belong in the tracking set.</p>
3746
+ ${renderProofChips(gsc.trackedButNoGsc, 6)}
3363
3747
  </div>`);
3364
3748
  }
3365
3749
  if (gsc.gscButNotTracked.length > 0) {
3366
3750
  crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
3367
- <p class="section-intro">GSC top queries (by impressions) that aren't tracked in your AEO project \u2014 candidates to add as queries.</p>
3368
- <ul>${gsc.gscButNotTracked.map((q) => `<li>${escapeHtml(q)}</li>`).join("")}</ul>
3751
+ <p class="section-intro">High-impression candidates to add to AEO tracking.</p>
3752
+ ${renderProofChips(gsc.gscButNotTracked, 6)}
3369
3753
  </div>`);
3370
3754
  }
3755
+ const dateRange = gscDateRange(report);
3371
3756
  return section(
3372
- { 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." },
3757
+ { id: "gsc", eyebrow: "Section 6", title: "GSC Performance", intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ""}.` },
3373
3758
  `<div class="metric-grid">
3374
3759
  <div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
3375
3760
  <div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
@@ -3383,12 +3768,7 @@ function renderGsc(report) {
3383
3768
  <tbody>${rows}</tbody>
3384
3769
  </table>
3385
3770
  </div>
3386
- <div class="chart-card"><h3>Category breakdown</h3>
3387
- <table class="report-table">
3388
- <thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
3389
- <tbody>${breakdownRows}</tbody>
3390
- </table>
3391
- </div>
3771
+ ${categoryBars}
3392
3772
  ${crossoverBlocks.join("\n")}`
3393
3773
  );
3394
3774
  }
@@ -3396,7 +3776,7 @@ function renderGa(report) {
3396
3776
  const ga = report.ga;
3397
3777
  if (!ga) {
3398
3778
  return section(
3399
- { id: "ga", eyebrow: "Section 6", title: "GA4 Traffic" },
3779
+ { id: "ga", eyebrow: "Section 7", title: "GA4 Traffic" },
3400
3780
  renderEmpty("Connect Google Analytics 4 to populate this section.")
3401
3781
  );
3402
3782
  }
@@ -3407,14 +3787,18 @@ function renderGa(report) {
3407
3787
  <td class="numeric">${formatNumber(p.users)}</td>
3408
3788
  <td class="numeric">${formatNumber(p.organicSessions)}</td>
3409
3789
  </tr>`).join("");
3410
- const channelRows = ga.channelBreakdown.map((c) => `
3411
- <tr>
3412
- <td>${escapeHtml(c.channel)}</td>
3413
- <td class="numeric">${formatNumber(c.sessions)}</td>
3414
- <td class="numeric">${c.sharePct}%</td>
3415
- </tr>`).join("");
3790
+ const channelBars = renderShareBars(
3791
+ "Channel mix",
3792
+ ga.channelBreakdown.map((c, index) => ({
3793
+ label: c.channel,
3794
+ count: c.sessions,
3795
+ sharePct: c.sharePct,
3796
+ color: COLORS.series[index % COLORS.series.length]
3797
+ })),
3798
+ "sessions"
3799
+ );
3416
3800
  return section(
3417
- { 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.` },
3801
+ { id: "ga", eyebrow: "Section 7", title: "GA4 Traffic", intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` },
3418
3802
  `<div class="metric-grid">
3419
3803
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
3420
3804
  <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
@@ -3426,28 +3810,27 @@ function renderGa(report) {
3426
3810
  <tbody>${pageRows}</tbody>
3427
3811
  </table>
3428
3812
  </div>
3429
- <div class="chart-card"><h3>Channel breakdown</h3>
3430
- <table class="report-table">
3431
- <thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
3432
- <tbody>${channelRows}</tbody>
3433
- </table>
3434
- </div>`
3813
+ ${channelBars}`
3435
3814
  );
3436
3815
  }
3437
3816
  function renderSocial(report) {
3438
3817
  const social = report.socialReferrals;
3439
3818
  if (!social) {
3440
3819
  return section(
3441
- { id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals" },
3820
+ { id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals" },
3442
3821
  renderEmpty("No social referral data yet.")
3443
3822
  );
3444
3823
  }
3445
- const channelRows = social.channels.map((c) => `
3446
- <tr>
3447
- <td>${escapeHtml(c.channelGroup)}</td>
3448
- <td class="numeric">${formatNumber(c.sessions)}</td>
3449
- <td class="numeric">${c.sharePct}%</td>
3450
- </tr>`).join("");
3824
+ const channelBars = renderShareBars(
3825
+ "Social channel mix",
3826
+ social.channels.map((c, index) => ({
3827
+ label: c.channelGroup,
3828
+ count: c.sessions,
3829
+ sharePct: c.sharePct,
3830
+ color: COLORS.series[index % COLORS.series.length]
3831
+ })),
3832
+ "sessions"
3833
+ );
3451
3834
  const campaignRows = social.topCampaigns.map((c) => `
3452
3835
  <tr>
3453
3836
  <td>${escapeHtml(c.source)}</td>
@@ -3455,18 +3838,13 @@ function renderSocial(report) {
3455
3838
  <td class="numeric">${formatNumber(c.sessions)}</td>
3456
3839
  </tr>`).join("");
3457
3840
  return section(
3458
- { 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." },
3841
+ { id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals", intro: "Social traffic split by channel and campaign." },
3459
3842
  `<div class="metric-grid">
3460
3843
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
3461
3844
  <div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
3462
3845
  <div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
3463
3846
  </div>
3464
- <div class="chart-card"><h3>Channel groups</h3>
3465
- <table class="report-table">
3466
- <thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
3467
- <tbody>${channelRows}</tbody>
3468
- </table>
3469
- </div>
3847
+ ${channelBars}
3470
3848
  <div class="chart-card"><h3>Top campaigns</h3>
3471
3849
  <table class="report-table">
3472
3850
  <thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
@@ -3479,17 +3857,20 @@ function renderAiReferrals(report) {
3479
3857
  const ai = report.aiReferrals;
3480
3858
  if (!ai) {
3481
3859
  return section(
3482
- { id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic" },
3860
+ { id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic" },
3483
3861
  renderEmpty("No AI referral traffic detected yet.")
3484
3862
  );
3485
3863
  }
3486
- const sourceRows = ai.bySource.map((s) => `
3487
- <tr>
3488
- <td>${escapeHtml(s.source)}</td>
3489
- <td class="numeric">${formatNumber(s.sessions)}</td>
3490
- <td class="numeric">${formatNumber(s.users)}</td>
3491
- <td class="numeric">${s.sharePct}%</td>
3492
- </tr>`).join("");
3864
+ const sourceBars = renderShareBars(
3865
+ "AI sessions by source",
3866
+ ai.bySource.map((s, index) => ({
3867
+ label: s.source,
3868
+ count: s.sessions,
3869
+ sharePct: s.sharePct,
3870
+ color: COLORS.series[(index + 2) % COLORS.series.length]
3871
+ })),
3872
+ "sessions"
3873
+ );
3493
3874
  const pageRows = ai.topLandingPages.map((p) => `
3494
3875
  <tr>
3495
3876
  <td class="page-cell">${formatLandingPageHtml(p.page)}</td>
@@ -3502,18 +3883,13 @@ function renderAiReferrals(report) {
3502
3883
  "AI referral sessions over time"
3503
3884
  );
3504
3885
  return section(
3505
- { 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." },
3886
+ { id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic", intro: "Traffic arriving from AI answer engines." },
3506
3887
  `<div class="metric-grid">
3507
3888
  <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
3508
3889
  <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
3509
3890
  </div>
3510
3891
  ${trendChart}
3511
- <div class="chart-card"><h3>Sessions by source</h3>
3512
- <table class="report-table">
3513
- <thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
3514
- <tbody>${sourceRows}</tbody>
3515
- </table>
3516
- </div>
3892
+ ${sourceBars}
3517
3893
  <div class="chart-card"><h3>Top AI landing pages</h3>
3518
3894
  <table class="report-table">
3519
3895
  <thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
@@ -3526,7 +3902,7 @@ function renderIndexingHealth(report) {
3526
3902
  const ih = report.indexingHealth;
3527
3903
  if (!ih) {
3528
3904
  return section(
3529
- { id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health" },
3905
+ { id: "indexing-health", eyebrow: "Section 10", title: "Indexing Health" },
3530
3906
  renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
3531
3907
  );
3532
3908
  }
@@ -3548,7 +3924,7 @@ function renderIndexingHealth(report) {
3548
3924
  }).join("");
3549
3925
  const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
3550
3926
  return section(
3551
- { 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.` },
3927
+ { id: "indexing-health", eyebrow: "Section 10", title: "Indexing Health", intro: `Pages absent from ${ih.provider === "google" ? "Google" : "Bing"} are harder for AI engines to retrieve.` },
3552
3928
  `<div class="metric-grid">
3553
3929
  <div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
3554
3930
  <div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
@@ -3565,14 +3941,14 @@ function renderCitationsTrend(report) {
3565
3941
  const trend = report.citationsTrend;
3566
3942
  if (trend.length === 0) {
3567
3943
  return section(
3568
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
3569
- renderEmpty("Run multiple visibility sweeps to see a trend.")
3944
+ { id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time" },
3945
+ renderEmpty("Run multiple checks to see a trend.")
3570
3946
  );
3571
3947
  }
3572
3948
  if (isTrendBaseline(trend)) {
3573
3949
  return section(
3574
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
3575
- renderEmpty(`Establishing baseline (${trend.length} of ${MIN_TREND_POINTS} runs collected). Trend will appear once more sweeps are recorded.`)
3950
+ { id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time" },
3951
+ renderEmpty(`Building baseline (${trend.length} of ${MIN_TREND_POINTS} checks completed). Trend will appear once more checks are recorded.`)
3576
3952
  );
3577
3953
  }
3578
3954
  const chart = renderLineChart(
@@ -3588,11 +3964,11 @@ function renderCitationsTrend(report) {
3588
3964
  <td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
3589
3965
  </tr>`).join("");
3590
3966
  return section(
3591
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Citation rate across every visibility sweep \u2014 the share of tracked queries cited by at least one provider, with a per-provider breakdown beneath. Computed per-query so the headline stays comparable across runs that ran a different mix of providers." },
3967
+ { id: "citations-trend", eyebrow: "Section 11", title: "Citations Over Time", intro: "Citation coverage across recent checks." },
3592
3968
  `${chart}
3593
- <div class="chart-card"><h3>Run-by-run breakdown</h3>
3969
+ <div class="chart-card"><h3>Check-by-check breakdown</h3>
3594
3970
  <table class="report-table">
3595
- <thead><tr><th>Run</th><th class="numeric">Cited queries</th><th>Per-provider rates</th></tr></thead>
3971
+ <thead><tr><th>Check</th><th class="numeric">Cited queries</th><th>Per-engine rates</th></tr></thead>
3596
3972
  <tbody>${rows}</tbody>
3597
3973
  </table>
3598
3974
  </div>`
@@ -3602,8 +3978,8 @@ function renderInsights(report) {
3602
3978
  const list = report.insights;
3603
3979
  if (list.length === 0) {
3604
3980
  return section(
3605
- { id: "insights", eyebrow: "Section 11", title: "Insights & Alerts" },
3606
- renderEmpty("No insights yet \u2014 run a visibility sweep to generate alerts.")
3981
+ { id: "insights", eyebrow: "Section 12", title: "Insights & Alerts" },
3982
+ renderEmpty("No insights yet \u2014 run a check to generate alerts.")
3607
3983
  );
3608
3984
  }
3609
3985
  const haveDeduped = list.every((i) => typeof i.instanceCount === "number");
@@ -3611,48 +3987,62 @@ function renderInsights(report) {
3611
3987
  const tone = severityTone(i.severity);
3612
3988
  const countChip = count > 1 ? ` <span class="badge tone-neutral">\xD7 ${count}</span>` : "";
3613
3989
  return `<tr>
3614
- <td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
3615
- <td>${escapeHtml(i.title)}${countChip}</td>
3616
- <td>${escapeHtml(i.query)}</td>
3617
- <td>${escapeHtml(i.provider)}</td>
3618
- <td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
3990
+ <td class="col-severity"><span class="badge tone-${tone}">${escapeHtml(reportSeverityLabel(i.severity))}</span></td>
3991
+ <td class="col-title">${escapeHtml(i.title)}${countChip}</td>
3992
+ <td class="col-query">${escapeHtml(i.query)}</td>
3993
+ <td class="col-provider">${escapeHtml(i.provider)}</td>
3994
+ <td class="col-recommendation">${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
3619
3995
  </tr>`;
3620
3996
  }).join("");
3621
3997
  return section(
3622
- { 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." },
3623
- `<table class="report-table">
3624
- <thead><tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th><th>Recommendation</th></tr></thead>
3998
+ { id: "insights", eyebrow: "Section 12", title: "Insights & Alerts", intro: "Regressions, gains, and recurring alerts ordered by severity." },
3999
+ `<table class="report-table insights-table">
4000
+ <thead><tr>
4001
+ <th class="col-severity">Severity</th>
4002
+ <th class="col-title">Title</th>
4003
+ <th class="col-query">Query</th>
4004
+ <th class="col-provider">Provider</th>
4005
+ <th class="col-recommendation">Recommendation</th>
4006
+ </tr></thead>
3625
4007
  <tbody>${rows}</tbody>
3626
4008
  </table>`
3627
4009
  );
3628
4010
  }
3629
4011
  function renderOpportunities(report) {
3630
- const opps = report.contentOpportunities;
4012
+ const opps = dedupeReportOpportunities(report);
3631
4013
  if (opps.length === 0) return "";
3632
4014
  const canonical = report.meta.project.canonicalDomain;
4015
+ const highlights = `<div class="opportunity-grid">
4016
+ ${opps.slice(0, 3).map((o) => `<article class="opportunity-card">
4017
+ <div class="opportunity-score" title="Opportunity score (0\u2013100, higher = stronger)">${Math.round(o.score)}<span class="opportunity-score-suffix">/100</span></div>
4018
+ <h3>${escapeHtml(o.query)}</h3>
4019
+ <p>${escapeHtml(contentActionLabel(o.action))} \xB7 ${escapeHtml(actionConfidenceLabel(o.actionConfidence))} confidence</p>
4020
+ ${renderProofChips(o.drivers, 2)}
4021
+ </article>`).join("")}
4022
+ </div>`;
3633
4023
  const rows = opps.slice(0, 10).map((o) => {
3634
4024
  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>';
3635
4025
  const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
3636
4026
  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>';
3637
4027
  return `<tr>
3638
4028
  <td>${escapeHtml(o.query)}</td>
3639
- <td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
3640
- <td class="numeric">${Math.round(o.score)}</td>
4029
+ <td><span class="badge tone-neutral">${escapeHtml(contentActionLabel(o.action))}</span></td>
4030
+ <td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
3641
4031
  <td>${drivers}</td>
3642
4032
  <td>${ourPage}</td>
3643
4033
  <td>${winning}</td>
3644
- <td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
4034
+ <td><span class="badge tone-neutral">${escapeHtml(actionConfidenceLabel(o.actionConfidence))}</span></td>
3645
4035
  </tr>`;
3646
4036
  }).join("");
3647
4037
  return section(
3648
4038
  {
3649
4039
  id: "content-opportunities",
3650
- eyebrow: "Section 12",
4040
+ eyebrow: "Section 13",
3651
4041
  title: "Content Opportunities",
3652
- intro: "Queries where you have search demand or competitor citation pressure but aren\u2019t winning AI citations. Each row pairs a suggested action (create / refresh / expand / add-schema) with the signals driving the score, the best matching page on your domain, and the competitor URL the AI most often cites. Top 10 shown."
4042
+ intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger."
3653
4043
  },
3654
- `<table class="report-table">
3655
- <thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
4044
+ `${highlights}<table class="report-table">
4045
+ <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>
3656
4046
  <tbody>${rows}</tbody>
3657
4047
  </table>`
3658
4048
  );
@@ -3673,9 +4063,9 @@ function renderContentGaps(report) {
3673
4063
  return section(
3674
4064
  {
3675
4065
  id: "content-gaps",
3676
- eyebrow: "Section 13",
4066
+ eyebrow: "Section 14",
3677
4067
  title: "Content Gaps",
3678
- intro: 'Tracked queries where multiple competitors are cited by AI engines but you are not \u2014 explicit "they are answering, you are missing" signal. Sorted by recent miss rate, then by number of competitors cited. Top 10 shown.'
4068
+ intro: "Tracked queries where competitors are cited and the client is missing."
3679
4069
  },
3680
4070
  `<table class="report-table">
3681
4071
  <thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
@@ -3687,7 +4077,7 @@ function renderRecommendedNextSteps(report) {
3687
4077
  const steps = report.recommendedNextSteps;
3688
4078
  if (steps.length === 0) {
3689
4079
  return section(
3690
- { id: "recommended-next-steps", eyebrow: "Section 14", 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." },
4080
+ { id: "recommended-next-steps", eyebrow: "Section 15", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
3691
4081
  renderEmpty("No outstanding actions.")
3692
4082
  );
3693
4083
  }
@@ -3698,7 +4088,7 @@ function renderRecommendedNextSteps(report) {
3698
4088
  <span class="rationale">${escapeHtml(s.rationale)}</span>
3699
4089
  </div>`).join("");
3700
4090
  return section(
3701
- { id: "recommended-next-steps", eyebrow: "Section 14", 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." },
4091
+ { id: "recommended-next-steps", eyebrow: "Section 15", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
3702
4092
  `<div class="steps">${items}</div>`
3703
4093
  );
3704
4094
  }
@@ -3708,33 +4098,45 @@ function actionAudienceMatches(action, audience) {
3708
4098
  function renderActionCards(actions) {
3709
4099
  if (actions.length === 0) return renderEmpty("No prioritized actions yet.");
3710
4100
  return `<div class="action-card-grid">
3711
- ${actions.map((action) => {
4101
+ ${actions.map((action, idx) => {
3712
4102
  const tone = reportActionTone(action);
3713
4103
  const why = action.why.length > 0 ? `<ul>${action.why.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
3714
4104
  const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>` : "";
4105
+ const proof = renderProofChips(action.evidence.length > 0 ? action.evidence : action.why, 3);
4106
+ const details = why || evidence ? `<details class="action-details">
4107
+ <summary>Evidence details</summary>
4108
+ ${why ? `<div><strong>Why</strong>${why}</div>` : ""}
4109
+ ${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
4110
+ </details>` : "";
3715
4111
  return `<article class="action-card">
3716
- <div class="action-meta">
3717
- <span class="badge tone-${tone}">${escapeHtml(action.horizon)}</span>
3718
- <span class="badge tone-neutral">${escapeHtml(action.category)}</span>
3719
- <span class="badge tone-neutral">${escapeHtml(action.confidence)} confidence</span>
4112
+ <div class="action-head">
4113
+ <div class="action-rank" title="Impact rank \u2014 1 is the highest-leverage action">${idx + 1}</div>
4114
+ <div>
4115
+ <div class="action-meta">
4116
+ <span class="badge tone-${tone}">${escapeHtml(reportHorizonLabel(action.horizon))}</span>
4117
+ <span class="badge tone-neutral">${escapeHtml(reportActionCategoryLabel(action.category))}</span>
4118
+ <span class="badge tone-neutral">${escapeHtml(reportConfidenceLabel(action.confidence))} confidence</span>
4119
+ </div>
4120
+ <h3>${escapeHtml(action.title)}</h3>
4121
+ </div>
3720
4122
  </div>
3721
- <h3>${escapeHtml(action.title)}</h3>
3722
4123
  <p>${escapeHtml(action.action)}</p>
3723
- ${why ? `<div><strong>Why</strong>${why}</div>` : ""}
3724
- ${evidence ? `<div><strong>Evidence</strong>${evidence}</div>` : ""}
3725
- <div class="success-metric"><strong>Success metric:</strong> ${escapeHtml(action.successMetric)}</div>
4124
+ ${proof}
4125
+ ${details}
4126
+ <div class="success-metric"><strong>Win condition:</strong> ${escapeHtml(action.successMetric)}</div>
3726
4127
  </article>`;
3727
4128
  }).join("")}
3728
4129
  </div>`;
3729
4130
  }
3730
4131
  function renderAudienceActionPlan(report, audience) {
3731
- const actions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
4132
+ const rawActions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
4133
+ const actions = dedupeReportActions(report, rawActions);
3732
4134
  return section(
3733
4135
  {
3734
4136
  id: audience === "client" ? "client-action-plan" : "agency-action-plan",
3735
4137
  eyebrow: audience === "client" ? "Client actions" : "Agency actions",
3736
4138
  title: audience === "client" ? "What We Recommend Next" : "Agency Action Plan",
3737
- intro: audience === "client" ? "Polished next steps the client can understand, backed by concise evidence from the report." : "Technical priorities pulled from the canonical action plan, sorted by urgency and evidence strength."
4139
+ intro: audience === "client" ? "The short list to approve and execute." : "The highest-leverage work, sorted by urgency and evidence strength."
3738
4140
  },
3739
4141
  renderActionCards(actions)
3740
4142
  );
@@ -3786,11 +4188,12 @@ function renderClientEvidenceSummary(report) {
3786
4188
  <ul><li>${formatNumber(report.indexingHealth.indexed)} indexed</li><li>${formatNumber(report.indexingHealth.notIndexed)} not indexed</li></ul>
3787
4189
  </div>`);
3788
4190
  }
3789
- if (report.contentOpportunities.length > 0) {
4191
+ const opportunities = dedupeReportOpportunities(report);
4192
+ if (opportunities.length > 0) {
3790
4193
  evidenceCards.push(`<div class="diagnostic-card tone-caution">
3791
4194
  <h3>Content opportunities</h3>
3792
4195
  <p>Canonry found topics where better content could improve AI citations.</p>
3793
- <ul>${report.contentOpportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
4196
+ <ul>${opportunities.slice(0, 5).map((o) => `<li>${escapeHtml(o.query)}: ${escapeHtml(o.action)} (${Math.round(o.score)})</li>`).join("")}</ul>
3794
4197
  </div>`);
3795
4198
  }
3796
4199
  return section(
@@ -3804,12 +4207,12 @@ function renderClientEvidenceSummary(report) {
3804
4207
  );
3805
4208
  }
3806
4209
  function renderAgencyDiagnostics(report) {
3807
- const diagnostics = report.agencyDiagnostics.diagnostics;
4210
+ const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== "Location caveat");
3808
4211
  const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
3809
4212
  ${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
3810
4213
  <h3>${escapeHtml(d.title)}</h3>
3811
4214
  <p>${escapeHtml(d.detail)}</p>
3812
- ${d.evidence.length > 0 ? `<ul>${d.evidence.map((e) => `<li>${escapeHtml(e)}</li>`).join("")}</ul>` : ""}
4215
+ ${renderProofChips(d.evidence, 3)}
3813
4216
  </div>`).join("")}
3814
4217
  </div>` : renderEmpty("No agency diagnostics available yet.");
3815
4218
  return section(
@@ -3817,7 +4220,7 @@ function renderAgencyDiagnostics(report) {
3817
4220
  id: "agency-diagnostics",
3818
4221
  eyebrow: "Agency diagnostics",
3819
4222
  title: "Technical Diagnostics",
3820
- intro: "Operator-facing diagnostics for content, provider, source-domain, search-demand, indexing, and location follow-up."
4223
+ intro: "Fast-read operator flags behind the action plan."
3821
4224
  },
3822
4225
  body
3823
4226
  );
@@ -3830,10 +4233,12 @@ function renderReportHtml(report, opts = {}) {
3830
4233
  const title = opts.title ?? `Canonry ${audience} report \u2014 ${report.meta.project.displayName}`;
3831
4234
  const sections = audience === "client" ? [
3832
4235
  renderClientSummary(report),
4236
+ renderWhatsChanged(report),
3833
4237
  renderAudienceActionPlan(report, "client"),
3834
4238
  renderClientEvidenceSummary(report)
3835
4239
  ].join("\n") : [
3836
4240
  renderExecutiveSummary(report),
4241
+ renderWhatsChanged(report),
3837
4242
  renderAudienceActionPlan(report, "agency"),
3838
4243
  renderAgencyDiagnostics(report),
3839
4244
  renderCitationScorecard(report),
@@ -3911,6 +4316,7 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
3911
4316
  ownDomain,
3912
4317
  competitors: trackedCompetitors,
3913
4318
  candidateQueries,
4319
+ queryIntentModifiers: buildQueryIntentModifiers(project, locationFilter),
3914
4320
  inventory,
3915
4321
  wpSchemaAudit: /* @__PURE__ */ new Map(),
3916
4322
  gaTrafficByPage,
@@ -3920,6 +4326,74 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
3920
4326
  inProgressActions: /* @__PURE__ */ new Map()
3921
4327
  };
3922
4328
  }
4329
+ function buildQueryIntentModifiers(project, locationFilter) {
4330
+ if (locationFilter === void 0 || locationFilter === null) return [];
4331
+ const locations = parseJsonColumn(project.locations, []);
4332
+ const currentLocation = locations.find((location) => location.label === locationFilter);
4333
+ const raw = currentLocation ? [
4334
+ currentLocation.label,
4335
+ currentLocation.city,
4336
+ currentLocation.region,
4337
+ regionAbbreviation(currentLocation.region),
4338
+ currentLocation.country
4339
+ ] : [locationFilter];
4340
+ return [...new Set(raw.map((value) => value.trim().toLowerCase()).filter(Boolean))];
4341
+ }
4342
+ function regionAbbreviation(region) {
4343
+ return US_REGION_ABBREVIATIONS[region.trim().toLowerCase()] ?? "";
4344
+ }
4345
+ var US_REGION_ABBREVIATIONS = {
4346
+ alabama: "al",
4347
+ alaska: "ak",
4348
+ arizona: "az",
4349
+ arkansas: "ar",
4350
+ california: "ca",
4351
+ colorado: "co",
4352
+ connecticut: "ct",
4353
+ delaware: "de",
4354
+ florida: "fl",
4355
+ georgia: "ga",
4356
+ hawaii: "hi",
4357
+ idaho: "id",
4358
+ illinois: "il",
4359
+ indiana: "in",
4360
+ iowa: "ia",
4361
+ kansas: "ks",
4362
+ kentucky: "ky",
4363
+ louisiana: "la",
4364
+ maine: "me",
4365
+ maryland: "md",
4366
+ massachusetts: "ma",
4367
+ michigan: "mi",
4368
+ minnesota: "mn",
4369
+ mississippi: "ms",
4370
+ missouri: "mo",
4371
+ montana: "mt",
4372
+ nebraska: "ne",
4373
+ nevada: "nv",
4374
+ "new hampshire": "nh",
4375
+ "new jersey": "nj",
4376
+ "new mexico": "nm",
4377
+ "new york": "ny",
4378
+ "north carolina": "nc",
4379
+ "north dakota": "nd",
4380
+ ohio: "oh",
4381
+ oklahoma: "ok",
4382
+ oregon: "or",
4383
+ pennsylvania: "pa",
4384
+ "rhode island": "ri",
4385
+ "south carolina": "sc",
4386
+ "south dakota": "sd",
4387
+ tennessee: "tn",
4388
+ texas: "tx",
4389
+ utah: "ut",
4390
+ vermont: "vt",
4391
+ virginia: "va",
4392
+ washington: "wa",
4393
+ "west virginia": "wv",
4394
+ wisconsin: "wi",
4395
+ wyoming: "wy"
4396
+ };
3923
4397
  function listQueries(db, projectId) {
3924
4398
  const rows = db.select({ text: queries.query }).from(queries).where(eq12(queries.projectId, projectId)).all();
3925
4399
  return rows.map((r) => r.text);
@@ -4158,6 +4632,14 @@ var TOP_LANDING_PAGES_LIMIT = 20;
4158
4632
  var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
4159
4633
  var TOP_CAMPAIGN_LIMIT = 10;
4160
4634
  var INSIGHT_LOOKBACK_RUNS = 5;
4635
+ var REPORT_WINDOW_DAYS = 30;
4636
+ function windowStartDate(endDate, windowDays) {
4637
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endDate);
4638
+ if (!m) return endDate;
4639
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
4640
+ d.setUTCDate(d.getUTCDate() - (windowDays - 1));
4641
+ return d.toISOString().slice(0, 10);
4642
+ }
4161
4643
  function safeNum(value) {
4162
4644
  if (typeof value === "number") return value;
4163
4645
  if (typeof value === "string") {
@@ -4193,7 +4675,12 @@ function loadQueryLookup(db, projectId) {
4193
4675
  return { byId };
4194
4676
  }
4195
4677
  function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
4196
- const rows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
4678
+ const allRows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
4679
+ if (allRows.length === 0) return null;
4680
+ let maxDate = "";
4681
+ for (const r of allRows) if (r.date > maxDate) maxDate = r.date;
4682
+ const startDate = windowStartDate(maxDate, REPORT_WINDOW_DAYS);
4683
+ const rows = allRows.filter((r) => r.date >= startDate && r.date <= maxDate);
4197
4684
  if (rows.length === 0) return null;
4198
4685
  let totalClicks = 0;
4199
4686
  let totalImpressions = 0;
@@ -4239,11 +4726,15 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
4239
4726
  sharePct: totalClicks > 0 ? Math.round(agg.clicks / totalClicks * 100) : 0
4240
4727
  })).sort((a, b) => b.clicks - a.clicks);
4241
4728
  const trend = [...trendAgg.entries()].map(([date, agg]) => ({ date, clicks: agg.clicks, impressions: agg.impressions })).sort((a, b) => a.date.localeCompare(b.date));
4729
+ const periodStart = trend[0]?.date ?? "";
4730
+ const periodEnd = trend.at(-1)?.date ?? "";
4242
4731
  const trackedSet = new Set(trackedQueries.map((q) => q.toLowerCase()));
4243
4732
  const gscQuerySet = new Set([...queryAgg.keys()].map((q) => q.toLowerCase()));
4244
4733
  const trackedButNoGsc = trackedQueries.filter((q) => !gscQuerySet.has(q.toLowerCase())).sort();
4245
4734
  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);
4246
4735
  return {
4736
+ periodStart,
4737
+ periodEnd,
4247
4738
  totalClicks,
4248
4739
  totalImpressions,
4249
4740
  ctr,
@@ -4256,14 +4747,24 @@ function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, tra
4256
4747
  };
4257
4748
  }
4258
4749
  function buildGaSection(db, projectId) {
4259
- const summaryRow = db.select().from(gaTrafficSummaries).where(eq13(gaTrafficSummaries.projectId, projectId)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
4260
- const snapshotRows = db.select().from(gaTrafficSnapshots).where(eq13(gaTrafficSnapshots.projectId, projectId)).all();
4261
- if (!summaryRow && snapshotRows.length === 0) return null;
4262
- const totalSessions = summaryRow?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
4263
- const totalUsers = summaryRow?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
4264
- const totalOrganicSessions = summaryRow?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
4750
+ const windowSummary = db.select().from(gaTrafficWindowSummaries).where(
4751
+ and4(
4752
+ eq13(gaTrafficWindowSummaries.projectId, projectId),
4753
+ eq13(gaTrafficWindowSummaries.windowKey, "30d")
4754
+ )
4755
+ ).limit(1).get();
4756
+ const fallbackSummary = windowSummary ? null : db.select().from(gaTrafficSummaries).where(eq13(gaTrafficSummaries.projectId, projectId)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
4757
+ const allSnapshotRows = db.select().from(gaTrafficSnapshots).where(eq13(gaTrafficSnapshots.projectId, projectId)).all();
4758
+ if (!windowSummary && !fallbackSummary && allSnapshotRows.length === 0) return null;
4759
+ let snapshotMaxDate = "";
4760
+ for (const r of allSnapshotRows) if (r.date > snapshotMaxDate) snapshotMaxDate = r.date;
4761
+ const snapshotStartDate = snapshotMaxDate ? windowStartDate(snapshotMaxDate, REPORT_WINDOW_DAYS) : "";
4762
+ const snapshotRows = snapshotStartDate ? allSnapshotRows.filter((r) => r.date >= snapshotStartDate && r.date <= snapshotMaxDate) : allSnapshotRows;
4763
+ const totalSessions = windowSummary?.totalSessions ?? fallbackSummary?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
4764
+ const totalUsers = windowSummary?.totalUsers ?? fallbackSummary?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
4765
+ const totalOrganicSessions = windowSummary?.totalOrganicSessions ?? fallbackSummary?.totalOrganicSessions ?? snapshotRows.reduce((s, r) => s + r.organicSessions, 0);
4265
4766
  const pageAgg = /* @__PURE__ */ new Map();
4266
- let directSessions = 0;
4767
+ let directSessions = windowSummary?.totalDirectSessions ?? 0;
4267
4768
  for (const r of snapshotRows) {
4268
4769
  const page = r.landingPageNormalized ?? r.landingPage;
4269
4770
  const existing = pageAgg.get(page) ?? { sessions: 0, users: 0, organic: 0 };
@@ -4271,7 +4772,7 @@ function buildGaSection(db, projectId) {
4271
4772
  existing.users += r.users;
4272
4773
  existing.organic += r.organicSessions;
4273
4774
  pageAgg.set(page, existing);
4274
- if (r.directSessions != null) directSessions += r.directSessions;
4775
+ if (!windowSummary && r.directSessions != null) directSessions += r.directSessions;
4275
4776
  }
4276
4777
  const topLandingPages = [...pageAgg.entries()].map(([page, data]) => ({
4277
4778
  page,
@@ -4299,12 +4800,14 @@ function buildGaSection(db, projectId) {
4299
4800
  }
4300
4801
  }
4301
4802
  }
4803
+ const periodStart = windowSummary?.periodStart ?? (snapshotStartDate || fallbackSummary?.periodStart || "");
4804
+ const periodEnd = windowSummary?.periodEnd ?? (snapshotMaxDate || fallbackSummary?.periodEnd || "");
4302
4805
  return {
4303
4806
  totalSessions,
4304
4807
  totalUsers,
4305
4808
  totalOrganicSessions,
4306
- periodStart: summaryRow?.periodStart ?? "",
4307
- periodEnd: summaryRow?.periodEnd ?? "",
4809
+ periodStart,
4810
+ periodEnd,
4308
4811
  topLandingPages,
4309
4812
  channelBreakdown
4310
4813
  };
@@ -4557,7 +5060,7 @@ function buildExecutiveFindings(citationRate, citedQueryCount, totalQueryCount,
4557
5060
  const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
4558
5061
  let detail;
4559
5062
  if (trendBaseline) {
4560
- detail = `Establishing baseline (${trendsPoints.length} of ${MIN_TREND_POINTS} runs collected).`;
5063
+ detail = `Building baseline (${trendsPoints.length} of ${MIN_TREND_POINTS} checks completed).`;
4561
5064
  } else {
4562
5065
  switch (trend) {
4563
5066
  case "up":
@@ -4657,7 +5160,7 @@ function buildReportActionPlan(input) {
4657
5160
  horizon: "immediate",
4658
5161
  category: "competitors",
4659
5162
  title: "Define the competitor set Canonry should benchmark against",
4660
- action: "Review the recurring external source domains and add the true competitors before the next sweep.",
5163
+ action: "Review the recurring external source domains and add the true competitors before the next check.",
4661
5164
  why: [
4662
5165
  "The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.",
4663
5166
  "A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients."
@@ -4691,7 +5194,7 @@ function buildReportActionPlan(input) {
4691
5194
  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.`,
4692
5195
  why: opportunity.drivers.length > 0 ? opportunity.drivers : ["Canonry ranked this as a content opportunity from search-demand and citation evidence."],
4693
5196
  evidence,
4694
- successMetric: `A future sweep cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
5197
+ successMetric: `A future check cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`,
4695
5198
  confidence: opportunity.actionConfidence
4696
5199
  });
4697
5200
  }
@@ -4731,7 +5234,7 @@ function buildReportActionPlan(input) {
4731
5234
  "This points the agency toward provider-specific evidence gaps instead of a generic content recommendation."
4732
5235
  ],
4733
5236
  evidence: zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`),
4734
- successMetric: "At least one zero-citation provider cites the client on a priority query in a later sweep.",
5237
+ successMetric: "At least one zero-citation engine cites the client on a priority query in a later check.",
4735
5238
  confidence: "high"
4736
5239
  });
4737
5240
  }
@@ -4795,13 +5298,13 @@ function buildReportActionPlan(input) {
4795
5298
  horizon: "medium-term",
4796
5299
  category: "location",
4797
5300
  title: "Keep location-scoped reporting separate by market",
4798
- action: "Run and compare separate sweeps for each configured location before making market-level recommendations.",
5301
+ action: "Run and compare separate checks for each configured location before making market-level recommendations.",
4799
5302
  why: [
4800
5303
  "A multi-location client can appear differently by market.",
4801
5304
  "Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story."
4802
5305
  ],
4803
5306
  evidence,
4804
- successMetric: "Each configured market has its own current sweep and trend before cross-market decisions are made.",
5307
+ successMetric: "Each configured market has its own current check and trend before cross-market decisions are made.",
4805
5308
  confidence: "high"
4806
5309
  });
4807
5310
  }
@@ -4812,10 +5315,10 @@ function buildReportActionPlan(input) {
4812
5315
  horizon: "short-term",
4813
5316
  category: "monitoring",
4814
5317
  title: "Keep monitoring citation and mention coverage",
4815
- action: "Run the next scheduled visibility sweep and watch for citation gains, losses, and provider-specific misses.",
5318
+ action: "Run the next scheduled check and watch for citation gains, losses, and engine-specific misses.",
4816
5319
  why: [
4817
5320
  "No urgent corrective action surfaced from the current evidence.",
4818
- "AEO performance is directional; repeated sweeps are needed before overreacting to a single sample."
5321
+ "AEO performance is directional; repeated checks are needed before overreacting to a single sample."
4819
5322
  ],
4820
5323
  evidence: ["No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report."],
4821
5324
  successMetric: "Coverage stays stable or improves across the next trend window.",
@@ -4827,11 +5330,11 @@ function buildReportActionPlan(input) {
4827
5330
  function trendSentence(trend) {
4828
5331
  switch (trend) {
4829
5332
  case "up":
4830
- return "Citation coverage improved versus the prior comparable sweep.";
5333
+ return "Citation coverage improved versus the prior comparable check.";
4831
5334
  case "down":
4832
- return "Citation coverage declined versus the prior comparable sweep.";
5335
+ return "Citation coverage declined versus the prior comparable check.";
4833
5336
  case "flat":
4834
- return "Citation coverage is flat versus the prior comparable sweep.";
5337
+ return "Citation coverage is flat versus the prior comparable check.";
4835
5338
  case "unknown":
4836
5339
  return "There is not enough comparable run history yet to call a trend.";
4837
5340
  }
@@ -4839,16 +5342,16 @@ function trendSentence(trend) {
4839
5342
  function buildClientSummary(reportLike) {
4840
5343
  const s = reportLike.executiveSummary;
4841
5344
  const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
4842
- 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";
4843
- 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.";
5345
+ const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a check yet";
5346
+ 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)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
4844
5347
  const confidenceNotes = [];
4845
5348
  if (s.totalQueryCount === 0) {
4846
- confidenceNotes.push("Confidence is low until the first tracked query sweep completes.");
5349
+ confidenceNotes.push("Confidence is low until the first tracked query check completes.");
4847
5350
  } else if (s.totalQueryCount < 5) {
4848
5351
  confidenceNotes.push("Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.");
4849
5352
  }
4850
5353
  if (isTrendBaseline(reportLike.citationsTrend)) {
4851
- confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable sweeps are needed for a stable trend.`);
5354
+ confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable checks are needed for a stable trend.`);
4852
5355
  }
4853
5356
  if (!reportLike.gsc) {
4854
5357
  confidenceNotes.push("Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.");
@@ -4868,13 +5371,13 @@ function buildAgencyDiagnostics(input) {
4868
5371
  const zeroCitationProviders = input.citationScorecard.providerRates.filter((p) => p.totalCount > 0 && p.citedCount === 0);
4869
5372
  diagnostics.push({
4870
5373
  title: "Provider citation coverage",
4871
- 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.",
5374
+ detail: zeroCitationProviders.length > 0 ? `${zeroCitationProviders.length} engine${zeroCitationProviders.length === 1 ? "" : "s"} returned zero client citations in the latest check.` : "Every provider with completed snapshots produced at least one client citation or no provider data is available yet.",
4872
5375
  severity: zeroCitationProviders.length > 0 ? "negative" : "positive",
4873
5376
  evidence: zeroCitationProviders.length > 0 ? zeroCitationProviders.map((p) => `${p.provider}: 0/${p.totalCount}`) : input.citationScorecard.providerRates.map((p) => `${p.provider}: ${p.citedCount}/${p.totalCount}`)
4874
5377
  });
4875
5378
  diagnostics.push({
4876
5379
  title: "AI source domains",
4877
- 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.",
5380
+ 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 check yet.",
4878
5381
  severity: input.aiSourceOrigin.topDomains.length > 0 ? "neutral" : "caution",
4879
5382
  evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map((d) => `${d.domain}: ${d.count}`)
4880
5383
  });
@@ -4906,22 +5409,117 @@ function buildAgencyDiagnostics(input) {
4906
5409
  severity: input.contentOpportunities.length > 0 ? "caution" : "neutral",
4907
5410
  evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
4908
5411
  });
4909
- if (input.reportLocation) {
4910
- diagnostics.push({
4911
- title: "Location caveat",
4912
- detail: input.reportLocation.otherConfiguredLabels.length > 0 ? "This report is scoped to the latest run location; other configured locations need separate interpretation." : "This report is scoped to one configured location.",
4913
- severity: input.reportLocation.otherConfiguredLabels.length > 0 ? "caution" : "neutral",
4914
- evidence: [
4915
- `Current location: ${input.reportLocation.label}`,
4916
- ...input.reportLocation.otherConfiguredLabels.length > 0 ? [`Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`] : []
4917
- ]
4918
- });
4919
- }
4920
5412
  return {
4921
5413
  priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
4922
5414
  diagnostics
4923
5415
  };
4924
5416
  }
5417
+ var WHATS_CHANGED_PERIOD_DAYS2 = 14;
5418
+ var WHATS_CHANGED_MIN_TREND_POINTS = WHATS_CHANGED_PERIOD_DAYS2 * 2;
5419
+ var WIN_REGRESSION_LIMIT = 5;
5420
+ function rateDirection(delta, threshold = 0.5) {
5421
+ if (delta > threshold) return "up";
5422
+ if (delta < -threshold) return "down";
5423
+ return "flat";
5424
+ }
5425
+ function periodOverPeriodDelta(trend) {
5426
+ if (trend.length < WHATS_CHANGED_MIN_TREND_POINTS) return null;
5427
+ const tail = trend.slice(-WHATS_CHANGED_PERIOD_DAYS2);
5428
+ const prior = trend.slice(-WHATS_CHANGED_PERIOD_DAYS2 * 2, -WHATS_CHANGED_PERIOD_DAYS2);
5429
+ const current = tail.reduce((s, p) => s + p.value, 0);
5430
+ const priorTotal = prior.reduce((s, p) => s + p.value, 0);
5431
+ const deltaAbs = current - priorTotal;
5432
+ return {
5433
+ current,
5434
+ prior: priorTotal,
5435
+ deltaAbs,
5436
+ direction: rateDirection(deltaAbs, 0)
5437
+ };
5438
+ }
5439
+ function buildWhatsChangedHeadline(citation, gscClicks, aiReferrals, enoughHistory, trendLength) {
5440
+ if (!enoughHistory) {
5441
+ return `Building baseline (${trendLength} of ${MIN_TREND_POINTS} checks completed). Trends appear after a few more checks.`;
5442
+ }
5443
+ const parts = [];
5444
+ if (citation) {
5445
+ const arrow = citation.direction === "up" ? "\u2191" : citation.direction === "down" ? "\u2193" : "\u2192";
5446
+ const verb = citation.direction === "up" ? "rose" : citation.direction === "down" ? "fell" : "held";
5447
+ parts.push(`Citation rate ${verb} ${citation.prior}% ${arrow} ${citation.current}%`);
5448
+ }
5449
+ if (aiReferrals && aiReferrals.direction !== "flat") {
5450
+ const arrow = aiReferrals.direction === "up" ? "\u2191" : "\u2193";
5451
+ parts.push(`AI referrals ${arrow}${Math.abs(aiReferrals.deltaAbs)} sessions vs prior 14 days`);
5452
+ } else if (gscClicks && gscClicks.direction !== "flat") {
5453
+ const arrow = gscClicks.direction === "up" ? "\u2191" : "\u2193";
5454
+ parts.push(`GSC clicks ${arrow}${Math.abs(gscClicks.deltaAbs)} vs prior 14 days`);
5455
+ }
5456
+ return parts.length > 0 ? `${parts.join(" \xB7 ")}.` : "No meaningful movement vs the prior period.";
5457
+ }
5458
+ function buildWhatsChanged(input) {
5459
+ const { citationsTrend, gsc, aiReferrals, insights: insightList } = input;
5460
+ const baseline = isTrendBaseline(citationsTrend);
5461
+ const latest = citationsTrend.at(-1);
5462
+ const prior = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
5463
+ const enoughHistory = !baseline && latest !== void 0 && prior !== void 0;
5464
+ const citationRate = enoughHistory ? {
5465
+ current: latest.citationRate,
5466
+ prior: prior.citationRate,
5467
+ deltaAbs: latest.citationRate - prior.citationRate,
5468
+ direction: rateDirection(latest.citationRate - prior.citationRate)
5469
+ } : null;
5470
+ const mentionRate = enoughHistory ? {
5471
+ current: latest.mentionRate,
5472
+ prior: prior.mentionRate,
5473
+ deltaAbs: latest.mentionRate - prior.mentionRate,
5474
+ direction: rateDirection(latest.mentionRate - prior.mentionRate)
5475
+ } : null;
5476
+ const citedQueryCount = enoughHistory ? {
5477
+ current: latest.citedQueryCount,
5478
+ prior: prior.citedQueryCount,
5479
+ deltaAbs: latest.citedQueryCount - prior.citedQueryCount,
5480
+ direction: rateDirection(latest.citedQueryCount - prior.citedQueryCount, 0)
5481
+ } : null;
5482
+ const providerMovements = [];
5483
+ if (enoughHistory) {
5484
+ const priorByProvider = new Map(prior.providerRates.map((p) => [p.provider, p.citationRate]));
5485
+ for (const cur of latest.providerRates) {
5486
+ const priorRate = priorByProvider.get(cur.provider);
5487
+ if (priorRate === void 0) continue;
5488
+ const deltaAbs = cur.citationRate - priorRate;
5489
+ providerMovements.push({
5490
+ provider: cur.provider,
5491
+ current: cur.citationRate,
5492
+ prior: priorRate,
5493
+ deltaAbs,
5494
+ direction: rateDirection(deltaAbs)
5495
+ });
5496
+ }
5497
+ providerMovements.sort((a, b) => Math.abs(b.deltaAbs) - Math.abs(a.deltaAbs));
5498
+ }
5499
+ const gscClicksDelta = gsc ? periodOverPeriodDelta(gsc.trend.map((t) => ({ date: t.date, value: t.clicks }))) : null;
5500
+ const aiReferralsDelta = aiReferrals ? periodOverPeriodDelta(aiReferrals.trend.map((t) => ({ date: t.date, value: t.sessions }))) : null;
5501
+ const wins = insightList.filter((i) => i.type === "gain").slice(0, WIN_REGRESSION_LIMIT);
5502
+ const regressions = insightList.filter((i) => i.type === "regression").slice(0, WIN_REGRESSION_LIMIT);
5503
+ const headline = buildWhatsChangedHeadline(
5504
+ citationRate,
5505
+ gscClicksDelta,
5506
+ aiReferralsDelta,
5507
+ enoughHistory,
5508
+ citationsTrend.length
5509
+ );
5510
+ return {
5511
+ enoughHistory,
5512
+ headline,
5513
+ citationRate,
5514
+ mentionRate,
5515
+ citedQueryCount,
5516
+ gscClicksDelta,
5517
+ aiReferralsDelta,
5518
+ providerMovements,
5519
+ wins,
5520
+ regressions
5521
+ };
5522
+ }
4925
5523
  function buildProjectReport(db, projectName) {
4926
5524
  const project = resolveProject(db, projectName);
4927
5525
  const queryLookup = loadQueryLookup(db, project.id);
@@ -4974,6 +5572,12 @@ function buildProjectReport(db, projectName) {
4974
5572
  contentOpportunities,
4975
5573
  insightDerivedSteps
4976
5574
  );
5575
+ const whatsChanged = buildWhatsChanged({
5576
+ citationsTrend,
5577
+ gsc: gscSection,
5578
+ aiReferrals: aiReferralsSection,
5579
+ insights: insightList
5580
+ });
4977
5581
  const totalQueryCount = queryLookup.byId.size;
4978
5582
  const citedQueryIds = /* @__PURE__ */ new Set();
4979
5583
  const mentionedQueryIds = /* @__PURE__ */ new Set();
@@ -5029,7 +5633,9 @@ function buildProjectReport(db, projectName) {
5029
5633
  clicks: gscSection.totalClicks,
5030
5634
  impressions: gscSection.totalImpressions,
5031
5635
  ctr: gscSection.ctr,
5032
- avgPosition: gscSection.avgPosition
5636
+ avgPosition: gscSection.avgPosition,
5637
+ periodStart: gscSection.periodStart,
5638
+ periodEnd: gscSection.periodEnd
5033
5639
  } : null,
5034
5640
  ga: gaSection ? {
5035
5641
  sessions: gaSection.totalSessions,
@@ -5099,6 +5705,7 @@ function buildProjectReport(db, projectName) {
5099
5705
  aiReferrals: aiReferralsSection,
5100
5706
  indexingHealth: indexingHealthSection,
5101
5707
  citationsTrend,
5708
+ whatsChanged,
5102
5709
  insights: insightList,
5103
5710
  recommendedNextSteps,
5104
5711
  actionPlan,