diff --git a/apps/web/src/pages/ReportPage.tsx b/apps/web/src/pages/ReportPage.tsx index 6caa14ba..0a3c5d2a 100644 --- a/apps/web/src/pages/ReportPage.tsx +++ b/apps/web/src/pages/ReportPage.tsx @@ -17,6 +17,8 @@ import { actionConfidenceLabel, CitationStates, contentActionLabel, + dedupeReportActions, + dedupeReportOpportunities, reportActionCategoryLabel, reportActionTone, reportConfidenceLabel, @@ -257,12 +259,14 @@ export function ReportPage({ projectName }: { projectName: string }) { {audience === 'client' ? ( <> + ) : ( <> + @@ -315,52 +319,73 @@ function ClientSummarySection({ report }: { report: ProjectReportDto }) { } function ActionPlanSection({ report, audience }: { report: ProjectReportDto; audience: ReportAudience }) { - const actions = audience === 'client' + const rawActions = audience === 'client' ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter(a => actionAudienceMatches(a, audience)) + const actions = dedupeReportActions(report, rawActions) return (
{actions.length === 0 ? ( ) : (
- {actions.map(action => ( -
-
- {reportHorizonLabel(action.horizon)} - {reportActionCategoryLabel(action.category)} - {reportConfidenceLabel(action.confidence)} confidence -
-

{action.title}

-

{action.action}

- {action.why.length > 0 && ( -
-

Why

-
    - {action.why.map((item, i) =>
  • {item}
  • )} -
-
- )} - {action.evidence.length > 0 && ( -
-

Evidence

-
    - {action.evidence.map((item, i) =>
  • {item}
  • )} -
+ {actions.map((action, idx) => { + const proof = action.evidence.length > 0 ? action.evidence : action.why + const hasDetails = action.why.length > 0 || action.evidence.length > 0 + return ( +
+
+
+ {idx + 1} +
+
+
+ {reportHorizonLabel(action.horizon)} + {reportActionCategoryLabel(action.category)} + {reportConfidenceLabel(action.confidence)} confidence +
+

{action.title}

+
- )} -

- Success metric: {action.successMetric} -

-
- ))} +

{action.action}

+ + {hasDetails && ( +
+ Evidence details + {action.why.length > 0 && ( +
+

Why

+
    + {action.why.map((item, i) =>
  • {item}
  • )} +
+
+ )} + {action.evidence.length > 0 && ( +
+

Evidence

+
    + {action.evidence.map((item, i) =>
  • {item}
  • )} +
+
+ )} +
+ )} +

+ Win condition: {action.successMetric} +

+ + ) + })}
)}
@@ -368,11 +393,11 @@ function ActionPlanSection({ report, audience }: { report: ProjectReportDto; aud } function AgencyDiagnosticsSection({ report }: { report: ProjectReportDto }) { - const diagnostics = report.agencyDiagnostics.diagnostics + const diagnostics = report.agencyDiagnostics.diagnostics.filter(d => d.title !== 'Location caveat') if (diagnostics.length === 0) return null return (
- +
{diagnostics.map(d => (
@@ -381,11 +406,7 @@ function AgencyDiagnosticsSection({ report }: { report: ProjectReportDto }) {

{d.title}

{d.detail}

- {d.evidence.length > 0 && ( -
    - {d.evidence.map((item, i) =>
  • {item}
  • )} -
- )} +
))} @@ -432,13 +453,14 @@ function ClientEvidenceSection({ report }: { report: ProjectReportDto }) { ], }) } - if (report.contentOpportunities.length > 0) { + const dedupedOpportunities = dedupeReportOpportunities(report) + if (dedupedOpportunities.length > 0) { cards.push({ key: 'content', tone: 'caution', title: 'Content opportunities', detail: 'Canonry found topics where better content could improve AI citations.', - items: report.contentOpportunities.slice(0, 5).map(o => `${o.query}: ${contentActionLabel(o.action)} (score ${Math.round(o.score)}/100)`), + items: dedupedOpportunities.slice(0, 5).map(o => `${o.query}: ${o.action} (${Math.round(o.score)})`), }) } @@ -483,13 +505,52 @@ function ExecutiveSummarySection({ report }: { report: ProjectReportDto }) { : 'no queries' const citationSuffix = `${trendArrow} · ${citedFragment} · ${exec.providerCount} provider${exec.providerCount === 1 ? '' : 's'}` const competitorSuffix = `${exec.competitorCount} competitor${exec.competitorCount === 1 ? '' : 's'} tracked` + const headlineTitle = exec.totalQueryCount > 0 + ? `${exec.citedQueryCount} of ${exec.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` + : 'No AI citation data yet' + const headlineSubtitle = exec.totalQueryCount > 0 + ? `${exec.citationRate}% citation coverage and ${exec.mentionRate}% mention coverage across ${exec.providerCount} provider${exec.providerCount === 1 ? '' : 's'}.` + : 'Run a check to populate the first citation and mention baseline.' + const priorityActions = report.agencyDiagnostics.priorities.length > 0 + ? report.agencyDiagnostics.priorities + : report.actionPlan + const actionCount = dedupeReportActions(report, priorityActions).length + const dateRange = gscDateRange(report) return (
+
+
+

Latest AI visibility check

+

{headlineTitle}

+

{headlineSubtitle}

+
+
+
+

Citation trend

+

{trendArrow}

+

{citedFragment}

+
+
+

Mention coverage

+

{exec.mentionRate}%

+

{mentionedFragment}

+
+
+

Prioritized actions

+

{formatNumber(actionCount)}

+

Sorted for agency follow-up.

+
+
+
@@ -498,7 +559,7 @@ function ExecutiveSummarySection({ report }: { report: ProjectReportDto }) { )} {exec.ga && ( @@ -534,6 +595,221 @@ function ExecutiveSummarySection({ report }: { report: ProjectReportDto }) { ) } +// ─── Section: What's Changed ─────────────────────────────────────────────── + +const WHATS_CHANGED_PERIOD_DAYS = 14 + +function deltaTone(direction: 'up' | 'down' | 'flat'): MetricTone { + if (direction === 'up') return 'positive' + if (direction === 'down') return 'negative' + return 'neutral' +} + +function deltaArrow(direction: 'up' | 'down' | 'flat'): string { + if (direction === 'up') return '↑' + if (direction === 'down') return '↓' + return '→' +} + +function RateDeltaTile({ + label, + delta, + unit, +}: { + label: string + delta: ProjectReportDto['whatsChanged']['citationRate'] + unit: '%' | 'count' +}) { + if (!delta) { + return ( +
+

{label}

+

+

No prior data

+
+ ) + } + const valueSuffix = unit === '%' ? '%' : '' + const sign = delta.deltaAbs > 0 ? '+' : '' + const tone = deltaTone(delta.direction) + const toneClass = tone === 'positive' ? 'text-emerald-400' + : tone === 'negative' ? 'text-rose-400' + : 'text-zinc-100' + return ( +
+

{label}

+

+ {delta.current}{valueSuffix} {deltaArrow(delta.direction)} +

+

+ {sign}{unit === '%' ? delta.deltaAbs.toFixed(1) : delta.deltaAbs}{valueSuffix} vs {delta.prior}{valueSuffix} +

+
+ ) +} + +function TrafficDeltaTile({ + label, + delta, + countLabel, +}: { + label: string + delta: ProjectReportDto['whatsChanged']['gscClicksDelta'] + countLabel: string +}) { + if (!delta) { + return ( +
+

{label}

+

+

Not enough trend data

+
+ ) + } + const sign = delta.deltaAbs > 0 ? '+' : '' + const tone = deltaTone(delta.direction) + const toneClass = tone === 'positive' ? 'text-emerald-400' + : tone === 'negative' ? 'text-rose-400' + : 'text-zinc-100' + return ( +
+

{label}

+

+ {formatNumber(delta.current)} {deltaArrow(delta.direction)} +

+

+ {sign}{formatNumber(delta.deltaAbs)} {countLabel} vs prior {WHATS_CHANGED_PERIOD_DAYS} days +

+
+ ) +} + +function ProviderMovementsTable({ + movements, +}: { + movements: ProjectReportDto['whatsChanged']['providerMovements'] +}) { + const meaningful = movements.filter(m => m.direction !== 'flat') + if (meaningful.length === 0) return null + return ( +
+

AI engine movements

+
+ + + + + + + + + + + {meaningful.map(m => { + const sign = m.deltaAbs > 0 ? '+' : '' + const tone = deltaTone(m.direction) + const cellClass = tone === 'positive' ? 'text-emerald-400' + : tone === 'negative' ? 'text-rose-400' + : 'text-zinc-300' + return ( + + + + + + + ) + })} + +
EnginePriorCurrentChange
{m.provider}{m.prior}%{m.current}% + {sign}{m.deltaAbs.toFixed(1)}% {deltaArrow(m.direction)} +
+
+
+ ) +} + +function WinsLossesTable({ + insights, + heading, + emptyMessage, +}: { + insights: readonly ReportInsight[] + heading: string + emptyMessage: string +}) { + if (insights.length === 0) { + return ( +
+

{heading}

+

{emptyMessage}

+
+ ) + } + return ( +
+

{heading}

+
+ + + + + + + + + + + {insights.map(i => ( + + + + + + + ))} + +
SeverityTitleQueryProvider
+ {reportSeverityLabel(i.severity)} + {i.instanceCount > 1 && ×{i.instanceCount}} + {i.title}{i.query}{i.provider}
+
+
+ ) +} + +function WhatsChangedSection({ report }: { report: ProjectReportDto }) { + const w = report.whatsChanged + const everythingEmpty = !w.enoughHistory + && !w.gscClicksDelta + && !w.aiReferralsDelta + && w.wins.length === 0 + && w.regressions.length === 0 + if (everythingEmpty) { + return ( +
+ + +
+ ) + } + return ( +
+ +
+ + + + + +
+ + + +
+ ) +} + function LocationHandlingCard({ report }: { report: ProjectReportDto }) { const location = report.meta.location const handling = report.meta.providerLocationHandling @@ -546,7 +822,7 @@ function LocationHandlingCard({ report }: { report: ProjectReportDto }) { {location ? formatLocationLabel(location) : 'none — providers received the queries verbatim with no geographic hint.'} {location && location.otherConfiguredLabels.length > 0 && ( - {' '}— other configured locations ({location.otherConfiguredLabels.join(', ')}) need their own sweep to compare. + {' '}— other configured locations ({location.otherConfiguredLabels.join(', ')}) need their own check to compare. )}

@@ -601,14 +877,14 @@ function CitationScorecardSection({ report }: { report: ProjectReportDto }) { if (sc.providers.length === 0 || sc.queries.length === 0) { return (
- +
) } return (
- +

Provider citation rate

@@ -687,8 +963,8 @@ function CompetitorLandscapeSection({ report }: { report: ProjectReportDto }) { if (noCitationData && noMentionData) { return (
- - + +
) } @@ -705,7 +981,7 @@ function CompetitorLandscapeSection({ report }: { report: ProjectReportDto }) { const mentionByDomain = new Map(ml.competitors.map(m => [m.domain, m])) return (
- +
{showCitationBars && (
@@ -812,101 +1088,96 @@ function AiSourceOriginSection({ report }: { report: ProjectReportDto }) { if (so.categories.length === 0 && so.topDomains.length === 0) { return (
- - + +
) } const totalCitations = so.categories.reduce((s, c) => s + c.count, 0) const competitor = so.categories.find(c => c.category === 'competitor') - const max = Math.max(1, ...so.categories.map(c => c.count)) return (
- + {competitor && (

{competitor.sharePct}% of citations went to tracked competitors ({competitor.count} of {totalCitations}).

)} -
-
-

Top source domains

- {so.topDomains.length === 0 ? ( - - ) : ( -
- - - - - - + {so.topDomains.length > 0 && ( +
+

Top sources

+
+
DomainCitationsTag
+ + + + + + + + + {so.topDomains.map(d => ( + + + + - - - {so.topDomains.map(d => ( - - - - - - ))} - -
DomainCitationsTag
{d.domain}{d.count} + {d.isCompetitor + ? Tracked competitor + : External} +
{d.domain}{d.count} - {d.isCompetitor - ? Tracked competitor - : External} -
-
- )} + ))} + + +
-
-

By source type

- {so.categories.length === 0 ? ( - - ) : ( -
-
- {so.categories.map(c => { - const pct = (c.count / max) * 100 - const tone = c.category === 'competitor' ? 'negative' : c.category === 'directory' || c.category === 'forum' ? 'caution' : 'neutral' - const fill = tone === 'negative' ? 'bg-rose-400' : tone === 'caution' ? 'bg-amber-400' : 'bg-blue-400' - return ( -
-
{c.label}
-
-
-
-
- {c.count} ({c.sharePct}%) -
-
- ) - })} -
-
- )} + )} + {so.categories.length > 0 && ( +
+ { + const tone = c.category === 'competitor' + ? '#f43f5e' + : c.category === 'directory' || c.category === 'forum' + ? '#f59e0b' + : '#3b82f6' + return { label: c.label, count: c.count, sharePct: c.sharePct, color: tone } + })} + countLabel="citations" + />
-
+ )}
) } // ─── Section: GSC performance ────────────────────────────────────────────── +function gscDateRange(report: ProjectReportDto): string { + const summary = report.executiveSummary.gsc + const gsc = report.gsc + const start = summary?.periodStart || gsc?.periodStart || gsc?.trend[0]?.date || '' + const end = summary?.periodEnd || gsc?.periodEnd || gsc?.trend.at(-1)?.date || '' + if (!start && !end) return '' + if (start && end) return `${formatDate(start)} → ${formatDate(end)}` + return formatDate(start || end) +} + function GscPerformanceSection({ report }: { report: ProjectReportDto }) { const gsc = report.gsc if (!gsc) { return (
- +
) } + const dateRange = gscDateRange(report) return (
- +
@@ -932,16 +1203,16 @@ function GscPerformanceSection({ report }: { report: ProjectReportDto }) { {gsc.topQueries.length > 0 && } {gsc.categoryBreakdown.length > 0 && (
-

Query categories

-
- {gsc.categoryBreakdown.map(c => ( -
-

{c.category}

-

{formatNumber(c.clicks)} clicks

-

{formatPercent(c.sharePct)} share · {formatNumber(c.impressions)} impressions

-
- ))} -
+ ({ + label: c.category, + count: c.clicks, + sharePct: c.sharePct, + color: CHART_SERIES_COLORS[i % CHART_SERIES_COLORS.length], + }))} + countLabel="clicks" + />
)} {(gsc.trackedButNoGsc.length > 0 || gsc.gscButNotTracked.length > 0) && ( @@ -967,22 +1238,22 @@ function TopQueriesTable({ rows }: { rows: GscQueryRow[] }) { Query - Category Clicks - Impr. + Imp. CTR Pos. + Category {rows.map(r => ( {r.query} - {r.category} {formatNumber(r.clicks)} {formatNumber(r.impressions)} {formatRatio(r.ctr)} {r.avgPosition.toFixed(1)} + {r.category} ))} @@ -1014,14 +1285,14 @@ function GaTrafficSection({ report }: { report: ProjectReportDto }) { if (!ga) { return (
- +
) } return (
- +
@@ -1041,7 +1312,7 @@ function GaTrafficSection({ report }: { report: ProjectReportDto }) { - {ga.topLandingPages.slice(0, 12).map(p => ( + {ga.topLandingPages.map(p => ( {p.page} {formatNumber(p.sessions)} @@ -1056,16 +1327,16 @@ function GaTrafficSection({ report }: { report: ProjectReportDto }) { )} {ga.channelBreakdown.length > 0 && (
-

Channel breakdown

-
- {ga.channelBreakdown.map(c => ( -
-

{c.channel}

-

{formatNumber(c.sessions)}

-

{formatPercent(c.sharePct)}

-
- ))} -
+ ({ + label: c.channel, + count: c.sessions, + sharePct: c.sharePct, + color: CHART_SERIES_COLORS[i % CHART_SERIES_COLORS.length], + }))} + countLabel="sessions" + />
)}
@@ -1079,14 +1350,14 @@ function SocialReferralsSection({ report }: { report: ProjectReportDto }) { if (!sr) { return (
- +
) } return (
- +
@@ -1094,16 +1365,16 @@ function SocialReferralsSection({ report }: { report: ProjectReportDto }) {
{sr.channels.length > 0 && (
-

By channel

-
- {sr.channels.map(c => ( -
-

{c.channelGroup}

-

{formatNumber(c.sessions)}

-

{formatPercent(c.sharePct)}

-
- ))} -
+ ({ + label: c.channelGroup, + count: c.sessions, + sharePct: c.sharePct, + color: CHART_SERIES_COLORS[i % CHART_SERIES_COLORS.length], + }))} + countLabel="sessions" + />
)} {sr.topCampaigns.length > 0 && ( @@ -1142,14 +1413,14 @@ function AiReferralsSection({ report }: { report: ProjectReportDto }) { if (!ai) { return (
- +
) } return (
- +
@@ -1172,16 +1443,16 @@ function AiReferralsSection({ report }: { report: ProjectReportDto }) { )} {ai.bySource.length > 0 && (
-

By source

-
- {ai.bySource.map(s => ( -
-

{s.source}

-

{formatNumber(s.sessions)}

-

{formatNumber(s.users)} users · {formatPercent(s.sharePct)}

-
- ))} -
+ ({ + label: s.source, + count: s.sessions, + sharePct: s.sharePct, + color: CHART_SERIES_COLORS[(i + 2) % CHART_SERIES_COLORS.length], + }))} + countLabel="sessions" + />
)} {ai.topLandingPages.length > 0 && ( @@ -1214,30 +1485,57 @@ function IndexingHealthSectionView({ report }: { report: ProjectReportDto }) { if (!ih || !ih.provider) { return (
- +
) } - const tone: MetricTone = ih.indexedPct >= 90 ? 'positive' : ih.indexedPct >= 70 ? 'caution' : 'negative' - const indexingSubtitle = `What share of your tracked URLs are currently indexed in ${ih.provider === 'google' ? 'Google' : 'Bing'} — sourced from ${ih.provider === 'google' ? 'Google Search Console URL Inspection' : 'Bing Webmaster Tools URL Inspection'}. Pages absent from the index can't be retrieved by AI engines either.` + const indexingSubtitle = `Pages absent from ${ih.provider === 'google' ? 'Google' : 'Bing'} are harder for AI engines to retrieve.` return (
- -
- - - - - + +
+ + +
+
) } -function DeindexedOrUnknown({ ih }: { ih: IndexingHealthSection }) { - if (ih.provider === 'google') return - return +function CoverageBreakdown({ ih }: { ih: IndexingHealthSection }) { + const segments = [ + { label: 'Indexed', count: ih.indexed, color: '#10b981' }, + { label: 'Not indexed', count: ih.notIndexed, color: '#f59e0b' }, + { label: 'Deindexed', count: ih.deindexed, color: '#f43f5e' }, + { label: 'Unknown', count: ih.unknown, color: '#71717a' }, + ].filter(s => s.count > 0) + const total = segments.reduce((s, x) => s + x.count, 0) || 1 + if (segments.length === 0) return null + return ( +
+

Coverage breakdown

+
+ {segments.map(s => ( +
+ ))} +
+
+ {segments.map(s => ( + + + {s.label}: + {s.count} + + ))} +
+
+ ) } // ─── Section: Citations trend ────────────────────────────────────────────── @@ -1247,22 +1545,22 @@ function CitationsTrendSection({ report }: { report: ProjectReportDto }) { if (trend.length === 0) { return (
- - + +
) } if (isTrendBaseline(trend)) { return (
- - + +
) } return (
- +
@@ -1287,14 +1585,14 @@ function PerProviderTrendTable({ trend }: { trend: CitationsTrendPoint[] }) { if (trend.length === 0) return null return (
-

Run-by-run breakdown

+

Check-by-check breakdown

- + - + @@ -1322,14 +1620,14 @@ function InsightsSection({ report }: { report: ProjectReportDto }) { if (report.insights.length === 0) { return (
- +
) } return (
- +
RunCheck Cited queriesPer-provider ratesPer-engine rates
@@ -1366,17 +1664,32 @@ function InsightsSection({ report }: { report: ProjectReportDto }) { // ─── Section: Content opportunities ──────────────────────────────────────── function ContentOpportunitiesSection({ report }: { report: ProjectReportDto }) { - if (report.contentOpportunities.length === 0) { - return null - } + const opps = dedupeReportOpportunities(report) + if (opps.length === 0) return null const canonical = report.meta.project.canonicalDomain + const highlights = opps.slice(0, 3) return (
+
+ {highlights.map(o => ( +
+
+ {Math.round(o.score)} + /100 +
+

{o.query}

+

+ {contentActionLabel(o.action)} · {actionConfidenceLabel(o.actionConfidence)} confidence +

+ +
+ ))} +
@@ -1391,14 +1704,11 @@ function ContentOpportunitiesSection({ report }: { report: ProjectReportDto }) { - {report.contentOpportunities.slice(0, 10).map(o => ( + {opps.slice(0, 10).map(o => ( - + + + + + + ` + }).join('') + return `

${escapeHtml(heading)}

+
{o.query} {contentActionLabel(o.action)} - {Math.round(o.score)} - / 100 - {Math.round(o.score)} {o.drivers.length > 0 ?
    {o.drivers.map((d, i) =>
  • {d}
  • )}
@@ -1429,9 +1739,9 @@ function ContentGapsSection({ report }: { report: ProjectReportDto }) { return (
@@ -1471,33 +1781,26 @@ function NextStepsSection({ report }: { report: ProjectReportDto }) { if (report.recommendedNextSteps.length === 0) { return (
- +
) } return (
- -
- {(['immediate', 'short-term', 'medium-term'] as const).map(h => { - const steps = report.recommendedNextSteps.filter(s => s.horizon === h) - if (steps.length === 0) return null - const tone: MetricTone = h === 'immediate' ? 'negative' : h === 'short-term' ? 'caution' : 'neutral' - return ( -
- {reportHorizonLabel(h)} -
    - {steps.map((s, i) => ( -
  • -

    {s.title}

    -

    {s.rationale}

    -
  • - ))} -
+ +
+ {report.recommendedNextSteps.map((s, i) => ( +
+ + {reportHorizonLabel(s.horizon)} + +
+

{s.title}

+

{s.rationale}

- ) - })} +
+ ))}
) @@ -1515,6 +1818,61 @@ function SectionHeading({ eyebrow, title, subtitle }: { eyebrow: string; title: ) } +function ProofChips({ items, limit = 3, className }: { items: readonly string[]; limit?: number; className?: string }) { + if (items.length === 0) return null + const visible = items.slice(0, limit) + const more = items.length - visible.length + return ( +
+ {visible.map((item, i) => ( + + {item} + + ))} + {more > 0 && ( + + +{more} more + + )} +
+ ) +} + +function ShareBars({ + heading, + rows, + countLabel, +}: { + heading: string + rows: ReadonlyArray<{ label: string; count: number; sharePct: number; color?: string }> + countLabel: string +}) { + const visible = rows.filter(r => r.count > 0 || r.sharePct > 0) + if (visible.length === 0) return null + return ( +
+

{heading}

+
+ {visible.map((r, i) => { + const pct = Math.max(0, Math.min(100, r.sharePct)) + const color = r.color ?? CHART_SERIES_COLORS[i % CHART_SERIES_COLORS.length] + return ( +
+
{r.label}
+
+
+
+
+ {formatNumber(r.count)} {countLabel} · {r.sharePct}% +
+
+ ) + })} +
+
+ ) +} + function EmptyHint({ message }: { message: string }) { return

{message}

} diff --git a/package.json b/package.json index 17ff48e5..110fcc8e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canonry", "private": true, - "version": "4.8.0", + "version": "4.10.1", "type": "module", "packageManager": "pnpm@10.28.2", "scripts": { diff --git a/packages/api-routes/src/report-renderer.ts b/packages/api-routes/src/report-renderer.ts index 9a299eb2..faffd073 100644 --- a/packages/api-routes/src/report-renderer.ts +++ b/packages/api-routes/src/report-renderer.ts @@ -13,6 +13,8 @@ import { actionConfidenceLabel, CitationStates, contentActionLabel, + dedupeReportActions, + dedupeReportOpportunities, reportActionCategoryLabel, reportActionTone, reportConfidenceLabel, @@ -713,94 +715,6 @@ function renderHeaderLocationFragment(location: ProjectReportDto['meta']['locati return ` · Market: ${escapeHtml(locationDisplay(location))}` } -const REPORT_INTENT_STOPWORDS = new Set([ - 'a', - 'an', - 'and', - 'for', - 'from', - 'in', - 'near', - 'of', - 'on', - 'or', - 'the', - 'to', -]) - -function reportIntentModifiers(report: ProjectReportDto): Set { - const location = report.meta.location - if (!location) return new Set() - return new Set( - [location.label, location.city, location.region, location.country] - .flatMap(tokenizeReportIntent) - .map(normalizeReportIntentToken) - .filter(Boolean), - ) -} - -function dedupeReportActions( - report: ProjectReportDto, - actions: readonly ReportActionPlanItem[], -): ReportActionPlanItem[] { - const modifiers = reportIntentModifiers(report) - if (actions.length <= 1 || modifiers.size === 0) return [...actions] - - const seen = new Set() - const result: ReportActionPlanItem[] = [] - for (const action of actions) { - if (action.category !== 'content') { - result.push(action) - continue - } - const key = reportIntentKey(extractActionQuery(action), modifiers) - if (!key || seen.has(key)) continue - seen.add(key) - result.push(action) - } - return result -} - -function dedupeReportOpportunities( - report: ProjectReportDto, -): ProjectReportDto['contentOpportunities'] { - const modifiers = reportIntentModifiers(report) - const opportunities = report.contentOpportunities - if (opportunities.length <= 1 || modifiers.size === 0) return opportunities - - const seen = new Set() - return opportunities.filter((opportunity) => { - const key = reportIntentKey(opportunity.query, modifiers) - if (!key || seen.has(key)) return false - seen.add(key) - return true - }) -} - -function extractActionQuery(action: ReportActionPlanItem): string { - return action.title.match(/"([^"]+)"/)?.[1] - ?? action.successMetric.match(/"([^"]+)"/)?.[1] - ?? action.title -} - -function reportIntentKey(value: string, modifiers: ReadonlySet): string { - const tokens = tokenizeReportIntent(value) - .map(normalizeReportIntentToken) - .filter(Boolean) - .filter(token => !REPORT_INTENT_STOPWORDS.has(token)) - .filter(token => !modifiers.has(token)) - return [...new Set(tokens)].sort().join(' ') -} - -function tokenizeReportIntent(value: string): string[] { - return value.toLowerCase().match(/[a-z0-9]+/g) ?? [] -} - -function normalizeReportIntentToken(token: string): string { - if (token.length > 4 && token.endsWith('ies')) return `${token.slice(0, -3)}y` - if (token.length > 4 && token.endsWith('s') && !token.endsWith('ss')) return token.slice(0, -1) - return token -} function renderLocationCard(report: ProjectReportDto): string { const location = report.meta.location @@ -816,9 +730,9 @@ function renderLocationCard(report: ProjectReportDto): string { const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : 'None' const interpretation = location ? otherLocations.length > 0 - ? `${otherLocations.length} configured ${pluralize(otherLocations.length, 'market')} still ${otherLocations.length === 1 ? 'needs' : 'need'} a matching sweep before cross-market recommendations.` + ? `${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 sweep; read findings as default-market or national results.' + : 'No geographic hint was attached to this check; read findings as default-market or national results.' const providerCopy = handling.length > 0 ? weakLocationProviders.length > 0 @@ -837,7 +751,7 @@ function renderLocationCard(report: ProjectReportDto): string {

Market Scope

-
Current sweep
+
Current check
${escapeHtml(marketValue)}
All findings below are scoped to this run.
@@ -873,7 +787,7 @@ function renderExecutiveSummary(report: ProjectReportDto): string { : 'No AI citation data yet' const headlineSubtitle = s.totalQueryCount > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, 'provider')}.` - : 'Run a visibility sweep to populate the first citation and mention baseline.' + : 'Run a check to populate the first citation and mention baseline.' const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan @@ -881,7 +795,7 @@ function renderExecutiveSummary(report: ProjectReportDto): string { const heroHtml = `
-
Latest AI visibility sweep
+
Latest AI visibility check
${escapeHtml(headlineTitle)}
${escapeHtml(headlineSubtitle)}
@@ -966,6 +880,129 @@ function renderExecutiveSummary(report: ProjectReportDto): string { ) } +function deltaToneClass(direction: 'up' | 'down' | 'flat'): string { + if (direction === 'up') return 'tone-positive' + if (direction === 'down') return 'tone-negative' + return '' +} + +function deltaArrow(direction: 'up' | 'down' | 'flat'): string { + if (direction === 'up') return '↑' + if (direction === 'down') return '↓' + return '→' +} + +function renderRateDeltaTile( + label: string, + delta: ProjectReportDto['whatsChanged']['citationRate'], + unit: '%' | 'count', +): string { + if (!delta) { + return `
${escapeHtml(label)}
No prior data
` + } + const valueSuffix = unit === '%' ? '%' : '' + const deltaSign = delta.deltaAbs > 0 ? '+' : '' + const deltaText = `${deltaSign}${delta.deltaAbs.toFixed(unit === '%' ? 1 : 0)}${valueSuffix} vs ${delta.prior}${valueSuffix}` + return `
+
${escapeHtml(label)}
+
${delta.current}${valueSuffix} ${deltaArrow(delta.direction)}
+
${deltaText}
+
` +} + +function renderTrafficDeltaTile( + label: string, + delta: ProjectReportDto['whatsChanged']['gscClicksDelta'], + countLabel: string, +): string { + if (!delta) { + return `
${escapeHtml(label)}
Not enough trend data
` + } + const deltaSign = delta.deltaAbs > 0 ? '+' : '' + const deltaText = `${deltaSign}${formatNumber(delta.deltaAbs)} ${countLabel} vs prior ${WHATS_CHANGED_PERIOD_DAYS} days` + return `
+
${escapeHtml(label)}
+
${formatNumber(delta.current)} ${deltaArrow(delta.direction)}
+
${deltaText}
+
` +} + +const WHATS_CHANGED_PERIOD_DAYS = 14 + +function renderProviderMovements( + movements: ProjectReportDto['whatsChanged']['providerMovements'], +): string { + const meaningful = movements.filter(m => m.direction !== 'flat') + if (meaningful.length === 0) return '' + const rows = meaningful.map(m => { + const sign = m.deltaAbs > 0 ? '+' : '' + return `
+ + + + + ` + }).join('') + return `

AI engine movements

+
${escapeHtml(m.provider)}${m.prior}%${m.current}%${sign}${m.deltaAbs.toFixed(1)}% ${deltaArrow(m.direction)}
+ + ${rows} +
EnginePriorCurrentChange
+
` +} + +function renderWinsLosses( + insights: readonly ReportInsight[], + heading: string, + emptyMessage: string, +): string { + if (insights.length === 0) { + return `

${escapeHtml(heading)}

+

${escapeHtml(emptyMessage)}

+
` + } + const rows = insights.map(i => { + const tone = severityTone(i.severity) + const countChip = i.instanceCount > 1 ? ` × ${i.instanceCount}` : '' + return `
${escapeHtml(reportSeverityLabel(i.severity))}${escapeHtml(i.title)}${countChip}${escapeHtml(i.query)}${escapeHtml(i.provider)}
+ + ${rows} +
SeverityTitleQueryProvider
+
` +} + +function renderWhatsChanged(report: ProjectReportDto): string { + const w = report.whatsChanged + if (!w.enoughHistory && !w.gscClicksDelta && !w.aiReferralsDelta && w.wins.length === 0 && w.regressions.length === 0) { + return section( + { id: 'whats-changed', eyebrow: 'Section 2', title: "What's Changed", intro: w.headline }, + renderEmpty('Trends will appear after a few more checks.'), + ) + } + const rateTiles = `
+ ${renderRateDeltaTile('Citation rate', w.citationRate, '%')} + ${renderRateDeltaTile('Mention rate', w.mentionRate, '%')} + ${renderRateDeltaTile('Cited queries', w.citedQueryCount, 'count')} + ${renderTrafficDeltaTile('GSC clicks', w.gscClicksDelta, 'clicks')} + ${renderTrafficDeltaTile('AI referral sessions', w.aiReferralsDelta, 'sessions')} +
` + const movements = renderProviderMovements(w.providerMovements) + const wins = renderWinsLosses(w.wins, 'Wins', 'No new gains in the latest check.') + const regressions = renderWinsLosses(w.regressions, 'Regressions', 'No new regressions in the latest check.') + return section( + { id: 'whats-changed', eyebrow: 'Section 2', title: "What's Changed", intro: w.headline }, + `${rateTiles}${movements}${wins}${regressions}`, + ) +} + function renderProviderBars(rates: ProjectReportDto['citationScorecard']['providerRates']): string { if (rates.length === 0) return '' const max = Math.max(...rates.map(r => r.citationRate), 100) @@ -997,7 +1034,7 @@ function renderProviderBars(rates: ProjectReportDto['citationScorecard']['provid function renderCitationMatrix(scorecard: ProjectReportDto['citationScorecard']): string { if (scorecard.queries.length === 0 || scorecard.providers.length === 0) { - return renderEmpty('Run a visibility sweep to populate the citation matrix.') + return renderEmpty('Run a check to populate the citation matrix.') } const headers = scorecard.providers.map(p => `${escapeHtml(p)}`).join('') const rows = scorecard.queries.map((q, qi) => { @@ -1036,7 +1073,7 @@ function renderCitationScorecard(report: ProjectReportDto): string { ${renderCitationMatrix(report.citationScorecard)} ` return section( - { id: 'citation-scorecard', eyebrow: 'Section 2', title: 'Citation Scorecard', intro: 'Provider-by-provider citation and mention coverage for the latest sweep.' }, + { id: 'citation-scorecard', eyebrow: 'Section 3', title: 'Citation Scorecard', intro: 'Per-engine citation and mention coverage from the latest check.' }, body, ) } @@ -1096,8 +1133,8 @@ function renderCompetitorLandscape(report: ProjectReportDto): string { const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0 if (noCitationData && noMentionData) { return section( - { id: 'competitor-landscape', eyebrow: 'Section 3', title: 'Competitor Landscape' }, - renderEmpty('No competitor data yet. Add competitors and run a visibility sweep.'), + { id: 'competitor-landscape', eyebrow: 'Section 4', title: 'Competitor Landscape' }, + renderEmpty('No competitor data yet. Add competitors and run a check.'), ) } @@ -1138,7 +1175,7 @@ function renderCompetitorLandscape(report: ProjectReportDto): string { return section( { id: 'competitor-landscape', - eyebrow: 'Section 3', + eyebrow: 'Section 4', title: 'Competitor Landscape', intro: 'Who AI engines cite and mention instead of the client.', }, @@ -1218,8 +1255,8 @@ function renderAiSourceOrigin(report: ProjectReportDto): string { const origin = report.aiSourceOrigin if (origin.categories.length === 0 && origin.topDomains.length === 0) { return section( - { id: 'ai-source-origin', eyebrow: 'Section 4', title: 'AI Citation Sources' }, - renderEmpty('No source data yet. Run a visibility sweep first.'), + { id: 'ai-source-origin', eyebrow: 'Section 5', title: 'AI Citation Sources' }, + renderEmpty('No source data yet. Run a check first.'), ) } @@ -1247,9 +1284,9 @@ function renderAiSourceOrigin(report: ProjectReportDto): string { return section( { id: 'ai-source-origin', - eyebrow: 'Section 4', + eyebrow: 'Section 5', title: 'AI Citation Sources', - intro: 'External domains AI engines trusted most in the latest sweep.', + intro: 'External domains AI engines cited most in the latest check.', }, `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`, ) @@ -1295,7 +1332,7 @@ function renderGsc(report: ProjectReportDto): string { const gsc = report.gsc if (!gsc) { return section( - { id: 'gsc', eyebrow: 'Section 5', title: 'GSC Performance' }, + { id: 'gsc', eyebrow: 'Section 6', title: 'GSC Performance' }, renderEmpty('Connect Google Search Console to populate this section.'), ) } @@ -1344,7 +1381,7 @@ function renderGsc(report: ProjectReportDto): string { const dateRange = gscDateRange(report) return section( - { id: 'gsc', eyebrow: 'Section 5', title: 'GSC Performance', intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ''}.` }, + { id: 'gsc', eyebrow: 'Section 6', title: 'GSC Performance', intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ''}.` }, `
Total clicks
${formatNumber(gsc.totalClicks)}
Total impressions
${formatNumber(gsc.totalImpressions)}
@@ -1367,7 +1404,7 @@ function renderGa(report: ProjectReportDto): string { const ga = report.ga if (!ga) { return section( - { id: 'ga', eyebrow: 'Section 6', title: 'GA4 Traffic' }, + { id: 'ga', eyebrow: 'Section 7', title: 'GA4 Traffic' }, renderEmpty('Connect Google Analytics 4 to populate this section.'), ) } @@ -1392,7 +1429,7 @@ function renderGa(report: ProjectReportDto): string { ) return section( - { id: 'ga', eyebrow: 'Section 6', title: 'GA4 Traffic', intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` }, + { id: 'ga', eyebrow: 'Section 7', title: 'GA4 Traffic', intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` }, `
Total sessions
${formatNumber(ga.totalSessions)}
Total users
${formatNumber(ga.totalUsers)}
@@ -1412,7 +1449,7 @@ function renderSocial(report: ProjectReportDto): string { const social = report.socialReferrals if (!social) { return section( - { id: 'social-referrals', eyebrow: 'Section 7', title: 'Social Referrals' }, + { id: 'social-referrals', eyebrow: 'Section 8', title: 'Social Referrals' }, renderEmpty('No social referral data yet.'), ) } @@ -1436,7 +1473,7 @@ function renderSocial(report: ProjectReportDto): string { `).join('') return section( - { id: 'social-referrals', eyebrow: 'Section 7', title: 'Social Referrals', intro: 'Social traffic split by channel and campaign.' }, + { id: 'social-referrals', eyebrow: 'Section 8', title: 'Social Referrals', intro: 'Social traffic split by channel and campaign.' }, `
Total sessions
${formatNumber(social.totalSessions)}
Organic social
${formatNumber(social.organicSessions)}
@@ -1456,7 +1493,7 @@ function renderAiReferrals(report: ProjectReportDto): string { const ai = report.aiReferrals if (!ai) { return section( - { id: 'ai-referrals', eyebrow: 'Section 8', title: 'AI Referral Traffic' }, + { id: 'ai-referrals', eyebrow: 'Section 9', title: 'AI Referral Traffic' }, renderEmpty('No AI referral traffic detected yet.'), ) } @@ -1486,7 +1523,7 @@ function renderAiReferrals(report: ProjectReportDto): string { ) return section( - { id: 'ai-referrals', eyebrow: 'Section 8', title: 'AI Referral Traffic', intro: 'Traffic arriving from AI answer engines.' }, + { id: 'ai-referrals', eyebrow: 'Section 9', title: 'AI Referral Traffic', intro: 'Traffic arriving from AI answer engines.' }, `
Total sessions
${formatNumber(ai.totalSessions)}
Total users
${formatNumber(ai.totalUsers)}
@@ -1506,7 +1543,7 @@ function renderIndexingHealth(report: ProjectReportDto): string { const ih = report.indexingHealth if (!ih) { return section( - { id: 'indexing-health', eyebrow: 'Section 9', title: 'Indexing Health' }, + { id: 'indexing-health', eyebrow: 'Section 10', title: 'Indexing Health' }, renderEmpty('Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.'), ) } @@ -1533,7 +1570,7 @@ function renderIndexingHealth(report: ProjectReportDto): string { const legend = segments.map(s => `${escapeHtml(s.label)}: ${s.count}`).join('') return section( - { id: 'indexing-health', eyebrow: 'Section 9', title: 'Indexing Health', intro: `Pages absent from ${ih.provider === 'google' ? 'Google' : 'Bing'} are harder for AI engines to retrieve.` }, + { 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.` }, `
Indexed
${formatNumber(ih.indexed)}
Total inspected
${formatNumber(ih.total)}
@@ -1551,15 +1588,15 @@ function renderCitationsTrend(report: ProjectReportDto): string { const trend = report.citationsTrend if (trend.length === 0) { return section( - { id: 'citations-trend', eyebrow: 'Section 10', title: 'Citations Over Time' }, - renderEmpty('Run multiple visibility sweeps to see a trend.'), + { id: 'citations-trend', eyebrow: 'Section 11', title: 'Citations Over Time' }, + renderEmpty('Run multiple checks to see a trend.'), ) } if (isTrendBaseline(trend)) { return section( - { id: 'citations-trend', eyebrow: 'Section 10', title: 'Citations Over Time' }, - renderEmpty(`Establishing baseline (${trend.length} of ${MIN_TREND_POINTS} runs collected). Trend will appear once more sweeps are recorded.`), + { id: 'citations-trend', eyebrow: 'Section 11', title: 'Citations Over Time' }, + renderEmpty(`Building baseline (${trend.length} of ${MIN_TREND_POINTS} checks completed). Trend will appear once more checks are recorded.`), ) } @@ -1578,11 +1615,11 @@ function renderCitationsTrend(report: ProjectReportDto): string { `).join('') return section( - { id: 'citations-trend', eyebrow: 'Section 10', title: 'Citations Over Time', intro: 'Citation coverage across completed visibility sweeps.' }, + { id: 'citations-trend', eyebrow: 'Section 11', title: 'Citations Over Time', intro: 'Citation coverage across recent checks.' }, `${chart} -

Run-by-run breakdown

+

Check-by-check breakdown

- + ${rows}
RunCited queriesPer-provider rates
CheckCited queriesPer-engine rates
`, @@ -1593,8 +1630,8 @@ function renderInsights(report: ProjectReportDto): string { const list = report.insights if (list.length === 0) { return section( - { id: 'insights', eyebrow: 'Section 11', title: 'Insights & Alerts' }, - renderEmpty('No insights yet — run a visibility sweep to generate alerts.'), + { id: 'insights', eyebrow: 'Section 12', title: 'Insights & Alerts' }, + renderEmpty('No insights yet — run a check to generate alerts.'), ) } @@ -1618,7 +1655,7 @@ function renderInsights(report: ProjectReportDto): string { }).join('') return section( - { id: 'insights', eyebrow: 'Section 11', title: 'Insights & Alerts', intro: 'Regressions, gains, and recurring alerts ordered by severity.' }, + { id: 'insights', eyebrow: 'Section 12', title: 'Insights & Alerts', intro: 'Regressions, gains, and recurring alerts ordered by severity.' }, ` @@ -1669,7 +1706,7 @@ function renderOpportunities(report: ProjectReportDto): string { return section( { id: 'content-opportunities', - eyebrow: 'Section 12', + eyebrow: 'Section 13', title: 'Content Opportunities', intro: 'Queries where content work has the clearest path to more AI citations. Opportunity score is 0–100, higher = stronger.', }, @@ -1696,7 +1733,7 @@ function renderContentGaps(report: ProjectReportDto): string { return section( { id: 'content-gaps', - eyebrow: 'Section 13', + eyebrow: 'Section 14', title: 'Content Gaps', intro: 'Tracked queries where competitors are cited and the client is missing.', }, @@ -1714,7 +1751,7 @@ function renderRecommendedNextSteps(report: ProjectReportDto): string { const steps = report.recommendedNextSteps if (steps.length === 0) { return section( - { id: 'recommended-next-steps', eyebrow: 'Section 14', title: 'Recommended Next Steps', intro: 'Action items bucketed by timing.' }, + { id: 'recommended-next-steps', eyebrow: 'Section 15', title: 'Recommended Next Steps', intro: 'Action items bucketed by timing.' }, renderEmpty('No outstanding actions.'), ) } @@ -1727,7 +1764,7 @@ function renderRecommendedNextSteps(report: ProjectReportDto): string { `).join('') return section( - { id: 'recommended-next-steps', eyebrow: 'Section 14', title: 'Recommended Next Steps', intro: 'Action items bucketed by timing.' }, + { id: 'recommended-next-steps', eyebrow: 'Section 15', title: 'Recommended Next Steps', intro: 'Action items bucketed by timing.' }, `
${items}
`, ) } @@ -1912,11 +1949,13 @@ export function renderReportHtml(report: ProjectReportDto, opts: RenderReportHtm const sections = audience === 'client' ? [ renderClientSummary(report), + renderWhatsChanged(report), renderAudienceActionPlan(report, 'client'), renderClientEvidenceSummary(report), ].join('\n') : [ renderExecutiveSummary(report), + renderWhatsChanged(report), renderAudienceActionPlan(report, 'agency'), renderAgencyDiagnostics(report), renderCitationScorecard(report), diff --git a/packages/api-routes/src/report.ts b/packages/api-routes/src/report.ts index 38a7d98c..22015003 100644 --- a/packages/api-routes/src/report.ts +++ b/packages/api-routes/src/report.ts @@ -32,7 +32,10 @@ import { type ReportAudience, type ReportInsight, type ReportProviderLocationHandling, + type ReportProviderMovement, + type ReportRateDelta, type SocialReferralSection, + type WhatsChangedSection, } from '@ainyc/canonry-contracts' import { buildAiSourceOrigin, @@ -756,7 +759,7 @@ function buildExecutiveFindings( : trend === 'up' ? 'positive' : trend === 'down' ? 'negative' : 'neutral' let detail: string if (trendBaseline) { - detail = `Establishing baseline (${trendsPoints.length} of ${MIN_TREND_POINTS} runs collected).` + detail = `Building baseline (${trendsPoints.length} of ${MIN_TREND_POINTS} checks completed).` } else { switch (trend) { case 'up': detail = 'Up from the previous run.'; break @@ -882,7 +885,7 @@ function buildReportActionPlan(input: ReportActionPlanInput): ReportActionPlanIt horizon: 'immediate', category: 'competitors', title: 'Define the competitor set Canonry should benchmark against', - action: 'Review the recurring external source domains and add the true competitors before the next sweep.', + action: 'Review the recurring external source domains and add the true competitors before the next check.', why: [ 'The report can identify repeated external sources, but it cannot separate competitors from publishers until competitors are configured.', 'A clean competitor set makes future share-of-voice and content-gap reporting easier to explain to clients.', @@ -922,7 +925,7 @@ function buildReportActionPlan(input: ReportActionPlanInput): ReportActionPlanIt ? opportunity.drivers : ['Canonry ranked this as a content opportunity from search-demand and citation evidence.'], evidence, - successMetric: `A future sweep cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`, + successMetric: `A future check cites ${input.canonicalDomain} for "${opportunity.query}" and the matching GSC query/page improves.`, confidence: opportunity.actionConfidence, }) } @@ -965,7 +968,7 @@ function buildReportActionPlan(input: ReportActionPlanInput): ReportActionPlanIt 'This points the agency toward provider-specific evidence gaps instead of a generic content recommendation.', ], evidence: zeroCitationProviders.map(p => `${p.provider}: 0/${p.totalCount} cited query-provider pairs`), - successMetric: 'At least one zero-citation provider cites the client on a priority query in a later sweep.', + successMetric: 'At least one zero-citation engine cites the client on a priority query in a later check.', confidence: 'high', }) } @@ -1034,13 +1037,13 @@ function buildReportActionPlan(input: ReportActionPlanInput): ReportActionPlanIt horizon: 'medium-term', category: 'location', title: 'Keep location-scoped reporting separate by market', - action: 'Run and compare separate sweeps for each configured location before making market-level recommendations.', + action: 'Run and compare separate checks for each configured location before making market-level recommendations.', why: [ 'A multi-location client can appear differently by market.', 'Keeping each report location-scoped avoids mixing Florida and Michigan evidence in the same client story.', ], evidence, - successMetric: 'Each configured market has its own current sweep and trend before cross-market decisions are made.', + successMetric: 'Each configured market has its own current check and trend before cross-market decisions are made.', confidence: 'high', }) } @@ -1052,10 +1055,10 @@ function buildReportActionPlan(input: ReportActionPlanInput): ReportActionPlanIt horizon: 'short-term', category: 'monitoring', title: 'Keep monitoring citation and mention coverage', - action: 'Run the next scheduled visibility sweep and watch for citation gains, losses, and provider-specific misses.', + action: 'Run the next scheduled check and watch for citation gains, losses, and engine-specific misses.', why: [ 'No urgent corrective action surfaced from the current evidence.', - 'AEO performance is directional; repeated sweeps are needed before overreacting to a single sample.', + 'AEO performance is directional; repeated checks are needed before overreacting to a single sample.', ], evidence: ['No critical insights, content gaps, indexing blockers, or provider-zero issues were detected in this report.'], successMetric: 'Coverage stays stable or improves across the next trend window.', @@ -1068,9 +1071,9 @@ function buildReportActionPlan(input: ReportActionPlanInput): ReportActionPlanIt function trendSentence(trend: ProjectReportDto['executiveSummary']['trend']): string { switch (trend) { - case 'up': return 'Citation coverage improved versus the prior comparable sweep.' - case 'down': return 'Citation coverage declined versus the prior comparable sweep.' - case 'flat': return 'Citation coverage is flat versus the prior comparable sweep.' + case 'up': return 'Citation coverage improved versus the prior comparable check.' + case 'down': return 'Citation coverage declined versus the prior comparable check.' + case 'flat': return 'Citation coverage is flat versus the prior comparable check.' case 'unknown': return 'There is not enough comparable run history yet to call a trend.' } } @@ -1089,19 +1092,19 @@ function buildClientSummary( const queryNoun = s.totalQueryCount === 1 ? 'query' : 'queries' 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' + : 'No tracked queries have completed a check yet' 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.' + : 'At least one completed check is needed before this can summarize how the brand appears in AI answers.' const confidenceNotes: string[] = [] if (s.totalQueryCount === 0) { - confidenceNotes.push('Confidence is low until the first tracked query sweep completes.') + confidenceNotes.push('Confidence is low until the first tracked query check completes.') } else if (s.totalQueryCount < 5) { confidenceNotes.push('Directional read: the tracked query set is still small, so each query has outsized impact on the percentage.') } if (isTrendBaseline(reportLike.citationsTrend)) { - confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable sweeps are needed for a stable trend.`) + confidenceNotes.push(`Trend confidence is still developing; ${MIN_TREND_POINTS} comparable checks are needed for a stable trend.`) } if (!reportLike.gsc) { confidenceNotes.push('Search Console is not connected, so content recommendations lean more heavily on citation and competitor evidence.') @@ -1128,7 +1131,7 @@ function buildAgencyDiagnostics(input: ReportActionPlanInput & { diagnostics.push({ title: 'Provider citation coverage', detail: zeroCitationProviders.length > 0 - ? `${zeroCitationProviders.length} provider${zeroCitationProviders.length === 1 ? '' : 's'} returned zero client citations in the latest sweep.` + ? `${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.', severity: zeroCitationProviders.length > 0 ? 'negative' : 'positive', evidence: zeroCitationProviders.length > 0 @@ -1140,7 +1143,7 @@ function buildAgencyDiagnostics(input: ReportActionPlanInput & { title: 'AI source domains', 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.', + : 'No external source-domain evidence is available from the latest check yet.', severity: input.aiSourceOrigin.topDomains.length > 0 ? 'neutral' : 'caution', evidence: input.aiSourceOrigin.topDomains.slice(0, 5).map(d => `${d.domain}: ${d.count}`), }) @@ -1186,6 +1189,158 @@ function buildAgencyDiagnostics(input: ReportActionPlanInput & { } } +// 14-day half-window for period-over-period traffic deltas (last 14 days vs +// the 14 days before that). Anchored on the trend tail so a stale sync still +// produces meaningful comparisons; below 28 points we return null instead of +// inventing motion. The choice of 14 (not 7) smooths weekday seasonality +// without dropping all the way back into the noise floor of single-day swings. +const WHATS_CHANGED_PERIOD_DAYS = 14 +const WHATS_CHANGED_MIN_TREND_POINTS = WHATS_CHANGED_PERIOD_DAYS * 2 + +const WIN_REGRESSION_LIMIT = 5 + +function rateDirection(delta: number, threshold = 0.5): 'up' | 'down' | 'flat' { + if (delta > threshold) return 'up' + if (delta < -threshold) return 'down' + return 'flat' +} + +function periodOverPeriodDelta( + trend: ReadonlyArray<{ date: string; value: number }>, +): ReportRateDelta | null { + if (trend.length < WHATS_CHANGED_MIN_TREND_POINTS) return null + const tail = trend.slice(-WHATS_CHANGED_PERIOD_DAYS) + const prior = trend.slice(-WHATS_CHANGED_PERIOD_DAYS * 2, -WHATS_CHANGED_PERIOD_DAYS) + const current = tail.reduce((s, p) => s + p.value, 0) + const priorTotal = prior.reduce((s, p) => s + p.value, 0) + const deltaAbs = current - priorTotal + return { + current, + prior: priorTotal, + deltaAbs, + direction: rateDirection(deltaAbs, 0), + } +} + +function buildWhatsChangedHeadline( + citation: ReportRateDelta | null, + gscClicks: ReportRateDelta | null, + aiReferrals: ReportRateDelta | null, + enoughHistory: boolean, + trendLength: number, +): string { + if (!enoughHistory) { + return `Building baseline (${trendLength} of ${MIN_TREND_POINTS} checks completed). Trends appear after a few more checks.` + } + const parts: string[] = [] + if (citation) { + const arrow = citation.direction === 'up' ? '↑' : citation.direction === 'down' ? '↓' : '→' + const verb = citation.direction === 'up' ? 'rose' : citation.direction === 'down' ? 'fell' : 'held' + parts.push(`Citation rate ${verb} ${citation.prior}% ${arrow} ${citation.current}%`) + } + if (aiReferrals && aiReferrals.direction !== 'flat') { + const arrow = aiReferrals.direction === 'up' ? '↑' : '↓' + parts.push(`AI referrals ${arrow}${Math.abs(aiReferrals.deltaAbs)} sessions vs prior 14 days`) + } else if (gscClicks && gscClicks.direction !== 'flat') { + const arrow = gscClicks.direction === 'up' ? '↑' : '↓' + parts.push(`GSC clicks ${arrow}${Math.abs(gscClicks.deltaAbs)} vs prior 14 days`) + } + return parts.length > 0 ? `${parts.join(' · ')}.` : 'No meaningful movement vs the prior period.' +} + +function buildWhatsChanged(input: { + citationsTrend: CitationsTrendPoint[] + gsc: ProjectReportDto['gsc'] + aiReferrals: ProjectReportDto['aiReferrals'] + insights: ReportInsight[] +}): WhatsChangedSection { + const { citationsTrend, gsc, aiReferrals, insights: insightList } = input + const baseline = isTrendBaseline(citationsTrend) + const latest = citationsTrend.at(-1) + const prior = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null + const enoughHistory = !baseline && latest !== undefined && prior !== undefined + + const citationRate: ReportRateDelta | null = enoughHistory + ? { + current: latest!.citationRate, + prior: prior!.citationRate, + deltaAbs: latest!.citationRate - prior!.citationRate, + direction: rateDirection(latest!.citationRate - prior!.citationRate), + } + : null + + const mentionRate: ReportRateDelta | null = enoughHistory + ? { + current: latest!.mentionRate, + prior: prior!.mentionRate, + deltaAbs: latest!.mentionRate - prior!.mentionRate, + direction: rateDirection(latest!.mentionRate - prior!.mentionRate), + } + : null + + const citedQueryCount: ReportRateDelta | null = enoughHistory + ? { + current: latest!.citedQueryCount, + prior: prior!.citedQueryCount, + deltaAbs: latest!.citedQueryCount - prior!.citedQueryCount, + direction: rateDirection(latest!.citedQueryCount - prior!.citedQueryCount, 0), + } + : null + + const providerMovements: ReportProviderMovement[] = [] + if (enoughHistory) { + const priorByProvider = new Map(prior!.providerRates.map(p => [p.provider, p.citationRate])) + for (const cur of latest!.providerRates) { + const priorRate = priorByProvider.get(cur.provider) + if (priorRate === undefined) continue + const deltaAbs = cur.citationRate - priorRate + providerMovements.push({ + provider: cur.provider, + current: cur.citationRate, + prior: priorRate, + deltaAbs, + direction: rateDirection(deltaAbs), + }) + } + providerMovements.sort((a, b) => Math.abs(b.deltaAbs) - Math.abs(a.deltaAbs)) + } + + const gscClicksDelta = gsc + ? periodOverPeriodDelta(gsc.trend.map(t => ({ date: t.date, value: t.clicks }))) + : null + const aiReferralsDelta = aiReferrals + ? periodOverPeriodDelta(aiReferrals.trend.map(t => ({ date: t.date, value: t.sessions }))) + : null + + const wins = insightList + .filter(i => i.type === 'gain') + .slice(0, WIN_REGRESSION_LIMIT) + const regressions = insightList + .filter(i => i.type === 'regression') + .slice(0, WIN_REGRESSION_LIMIT) + + const headline = buildWhatsChangedHeadline( + citationRate, + gscClicksDelta, + aiReferralsDelta, + enoughHistory, + citationsTrend.length, + ) + + return { + enoughHistory, + headline, + citationRate, + mentionRate, + citedQueryCount, + gscClicksDelta, + aiReferralsDelta, + providerMovements, + wins, + regressions, + } +} + function buildProjectReport(db: DatabaseClient, projectName: string): ProjectReportDto { const project = resolveProject(db, projectName) const queryLookup = loadQueryLookup(db, project.id) @@ -1256,6 +1411,13 @@ function buildProjectReport(db: DatabaseClient, projectName: string): ProjectRep insightDerivedSteps, ) + const whatsChanged = buildWhatsChanged({ + citationsTrend, + gsc: gscSection, + aiReferrals: aiReferralsSection, + insights: insightList, + }) + // Headline rate is per-query — see buildCitationsTrend for the rationale. // Same definition both places so the trend chart and the executive summary // KPI move together; using different denominators in the two surfaces is @@ -1424,6 +1586,7 @@ function buildProjectReport(db: DatabaseClient, projectName: string): ProjectRep aiReferrals: aiReferralsSection, indexingHealth: indexingHealthSection, citationsTrend, + whatsChanged, insights: insightList, recommendedNextSteps, actionPlan, diff --git a/packages/api-routes/test/report-renderer.test.ts b/packages/api-routes/test/report-renderer.test.ts index 2e245ec3..b0471869 100644 --- a/packages/api-routes/test/report-renderer.test.ts +++ b/packages/api-routes/test/report-renderer.test.ts @@ -43,6 +43,18 @@ function emptyReport(): ProjectReportDto { aiReferrals: null, indexingHealth: null, citationsTrend: [], + whatsChanged: { + enoughHistory: false, + headline: 'Building baseline (0 of 4 checks completed). Trends appear after a few more checks.', + citationRate: null, + mentionRate: null, + citedQueryCount: null, + gscClicksDelta: null, + aiReferralsDelta: null, + providerMovements: [], + wins: [], + regressions: [], + }, insights: [], recommendedNextSteps: [], actionPlan: [], @@ -248,6 +260,18 @@ function richReport(): ProjectReportDto { { runId: 'r-1', date: '2026-04-01T00:00:00Z', citationRate: 50, citedQueryCount: 2, totalQueryCount: 4, mentionRate: 25, mentionedQueryCount: 1, providerRates: [{ provider: 'gemini', citationRate: 50 }] }, { runId: 'r-2', date: '2026-04-15T00:00:00Z', citationRate: 65, citedQueryCount: 3, totalQueryCount: 5, mentionRate: 40, mentionedQueryCount: 2, providerRates: [{ provider: 'gemini', citationRate: 65 }] }, ], + whatsChanged: { + enoughHistory: false, + headline: 'Building baseline (2 of 4 checks completed). Trends appear after a few more checks.', + citationRate: null, + mentionRate: null, + citedQueryCount: null, + gscClicksDelta: null, + aiReferralsDelta: null, + providerMovements: [], + wins: [], + regressions: [], + }, insights: [ { id: 'i-1', @@ -472,7 +496,7 @@ describe('renderReportHtml', () => { const html = renderReportHtml(richReport()) const executive = html.split('id="executive-summary"')[1]?.split('id="agency-action-plan"')[0] ?? '' expect(executive).toContain('Market Scope') - expect(executive).toContain('Current sweep') + expect(executive).toContain('Current check') expect(executive).toContain('Not included') expect(executive).toContain('florida') expect(executive).not.toContain('Location handling') @@ -827,7 +851,7 @@ describe('renderReportHtml', () => { ] const html = renderReportHtml(report) const block = html.split('id="citations-trend"')[1]?.split('')[0] ?? '' - expect(block.toLowerCase()).toContain('establishing baseline') + expect(block.toLowerCase()).toContain('building baseline') expect(block).not.toContain(' { const html = renderReportHtml(report) const block = html.split('id="citations-trend"')[1]?.split('')[0] ?? '' expect(block).toContain(' { expect(body.executiveSummary.trend).toBe('up') }) - test('findings detail surfaces "Establishing baseline" copy until enough runs exist', async () => { + test('whatsChanged reports baseline state when there is no prior run', async () => { + insertProject(ctx.db, 'wc-empty') + await ctx.app.ready() + const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/projects/wc-empty/report' }) + const body = JSON.parse(res.body) as ProjectReportDto + + expect(body.whatsChanged.enoughHistory).toBe(false) + expect(body.whatsChanged.headline).toMatch(/Building baseline/i) + expect(body.whatsChanged.citationRate).toBeNull() + expect(body.whatsChanged.mentionRate).toBeNull() + expect(body.whatsChanged.citedQueryCount).toBeNull() + expect(body.whatsChanged.providerMovements).toEqual([]) + }) + + test('whatsChanged computes run-over-run rate deltas once enough history exists', async () => { + const projectId = insertProject(ctx.db, 'wc-deltas') + const kw1 = insertQuery(ctx.db, projectId, 'kw1') + const kw2 = insertQuery(ctx.db, projectId, 'kw2') + + // 4 runs: cited rate climbs from 0% → 100% on kw1, kw2 stays not-cited. + for (let i = 0; i < 4; i++) { + const day = String(i + 1).padStart(2, '0') + const runId = insertRun(ctx.db, projectId, { + createdAt: `2026-04-${day}T00:00:00Z`, + finishedAt: `2026-04-${day}T00:01:00Z`, + }) + insertSnapshot(ctx.db, runId, kw1, { provider: 'gemini', citationState: i === 3 ? 'cited' : 'not-cited' }) + insertSnapshot(ctx.db, runId, kw2, { provider: 'gemini', citationState: 'not-cited' }) + } + + await ctx.app.ready() + const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/projects/wc-deltas/report' }) + const body = JSON.parse(res.body) as ProjectReportDto + + expect(body.whatsChanged.enoughHistory).toBe(true) + expect(body.whatsChanged.citationRate).not.toBeNull() + expect(body.whatsChanged.citationRate!.current).toBe(50) + expect(body.whatsChanged.citationRate!.prior).toBe(0) + expect(body.whatsChanged.citationRate!.deltaAbs).toBe(50) + expect(body.whatsChanged.citationRate!.direction).toBe('up') + expect(body.whatsChanged.citedQueryCount!.current).toBe(1) + expect(body.whatsChanged.citedQueryCount!.prior).toBe(0) + expect(body.whatsChanged.providerMovements.length).toBe(1) + expect(body.whatsChanged.providerMovements[0]!.provider).toBe('gemini') + expect(body.whatsChanged.providerMovements[0]!.direction).toBe('up') + expect(body.whatsChanged.headline).toMatch(/Citation rate rose/i) + }) + + test('whatsChanged surfaces wins and regressions from insights', async () => { + const projectId = insertProject(ctx.db, 'wc-insights') + const runId = insertRun(ctx.db, projectId) + + ctx.db.insert(insights).values([ + { + id: 'gain-1', + projectId, + runId, + type: 'gain', + severity: 'high', + title: 'New citation on aeo platform', + query: 'aeo platform', + provider: 'gemini', + recommendation: null, + cause: null, + dismissed: false, + createdAt: '2026-04-30T00:00:00Z', + }, + { + id: 'reg-1', + projectId, + runId, + type: 'regression', + severity: 'critical', + title: 'Lost citation on answer engine optimization', + query: 'answer engine optimization', + provider: 'openai', + recommendation: null, + cause: null, + dismissed: false, + createdAt: '2026-04-30T00:00:00Z', + }, + ]).run() + + await ctx.app.ready() + const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/projects/wc-insights/report' }) + const body = JSON.parse(res.body) as ProjectReportDto + + expect(body.whatsChanged.wins.length).toBe(1) + expect(body.whatsChanged.wins[0]!.type).toBe('gain') + expect(body.whatsChanged.regressions.length).toBe(1) + expect(body.whatsChanged.regressions[0]!.type).toBe('regression') + }) + + test('findings detail surfaces "Building baseline" copy until enough runs exist', async () => { const projectId = insertProject(ctx.db, 'trend-baseline') const kw = insertQuery(ctx.db, projectId, 'kw') @@ -826,7 +919,7 @@ describe('GET /api/v1/projects/:name/report', () => { const trendFinding = body.executiveSummary.findings.find(f => f.title.startsWith('Citation rate')) expect(trendFinding).toBeDefined() - expect(trendFinding!.detail).toMatch(/Establishing baseline/i) + expect(trendFinding!.detail).toMatch(/Building baseline/i) expect(trendFinding!.tone).toBe('neutral') }) diff --git a/packages/canonry/package.json b/packages/canonry/package.json index 2ccb7cbd..672266b7 100644 --- a/packages/canonry/package.json +++ b/packages/canonry/package.json @@ -1,6 +1,6 @@ { "name": "@ainyc/canonry", - "version": "4.8.0", + "version": "4.10.1", "type": "module", "description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain", "license": "FSL-1.1-ALv2", diff --git a/packages/canonry/test/report-command.test.ts b/packages/canonry/test/report-command.test.ts index 34a289e5..17e9d56d 100644 --- a/packages/canonry/test/report-command.test.ts +++ b/packages/canonry/test/report-command.test.ts @@ -45,6 +45,18 @@ function makeReport(): ProjectReportDto { aiReferrals: null, indexingHealth: null, citationsTrend: [], + whatsChanged: { + enoughHistory: false, + headline: 'Building baseline (0 of 4 checks completed). Trends appear after a few more checks.', + citationRate: null, + mentionRate: null, + citedQueryCount: null, + gscClicksDelta: null, + aiReferralsDelta: null, + providerMovements: [], + wins: [], + regressions: [], + }, insights: [], recommendedNextSteps: [], actionPlan: [], diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f5e4181b..076646af 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -24,4 +24,5 @@ export * from './doctor.js' export * from './url-normalize.js' export * from './citations.js' export * from './report.js' +export * from './report-dedup.js' export * from './skills.js' diff --git a/packages/contracts/src/report-dedup.ts b/packages/contracts/src/report-dedup.ts new file mode 100644 index 00000000..386b1f34 --- /dev/null +++ b/packages/contracts/src/report-dedup.ts @@ -0,0 +1,90 @@ +import type { ProjectReportDto, ReportActionPlanItem } from './report.js' + +const REPORT_INTENT_STOPWORDS = new Set([ + 'a', + 'an', + 'and', + 'for', + 'from', + 'in', + 'near', + 'of', + 'on', + 'or', + 'the', + 'to', +]) + +function tokenizeReportIntent(value: string): string[] { + return value.toLowerCase().match(/[a-z0-9]+/g) ?? [] +} + +function normalizeReportIntentToken(token: string): string { + if (token.length > 4 && token.endsWith('ies')) return `${token.slice(0, -3)}y` + if (token.length > 4 && token.endsWith('s') && !token.endsWith('ss')) return token.slice(0, -1) + return token +} + +export function reportIntentModifiers(report: ProjectReportDto): Set { + const location = report.meta.location + if (!location) return new Set() + return new Set( + [location.label, location.city, location.region, location.country] + .flatMap(tokenizeReportIntent) + .map(normalizeReportIntentToken) + .filter(Boolean), + ) +} + +function reportIntentKey(value: string, modifiers: ReadonlySet): string { + const tokens = tokenizeReportIntent(value) + .map(normalizeReportIntentToken) + .filter(Boolean) + .filter(token => !REPORT_INTENT_STOPWORDS.has(token)) + .filter(token => !modifiers.has(token)) + return [...new Set(tokens)].sort().join(' ') +} + +function extractActionQuery(action: ReportActionPlanItem): string { + return action.title.match(/"([^"]+)"/)?.[1] + ?? action.successMetric.match(/"([^"]+)"/)?.[1] + ?? action.title +} + +export function dedupeReportActions( + report: ProjectReportDto, + actions: readonly ReportActionPlanItem[], +): ReportActionPlanItem[] { + const modifiers = reportIntentModifiers(report) + if (actions.length <= 1 || modifiers.size === 0) return [...actions] + + const seen = new Set() + const result: ReportActionPlanItem[] = [] + for (const action of actions) { + if (action.category !== 'content') { + result.push(action) + continue + } + const key = reportIntentKey(extractActionQuery(action), modifiers) + if (!key || seen.has(key)) continue + seen.add(key) + result.push(action) + } + return result +} + +export function dedupeReportOpportunities( + report: ProjectReportDto, +): ProjectReportDto['contentOpportunities'] { + const modifiers = reportIntentModifiers(report) + const opportunities = report.contentOpportunities + if (opportunities.length <= 1 || modifiers.size === 0) return opportunities + + const seen = new Set() + return opportunities.filter((opportunity) => { + const key = reportIntentKey(opportunity.query, modifiers) + if (!key || seen.has(key)) return false + seen.add(key) + return true + }) +} diff --git a/packages/contracts/src/report.ts b/packages/contracts/src/report.ts index 6bdcdedc..e7c75426 100644 --- a/packages/contracts/src/report.ts +++ b/packages/contracts/src/report.ts @@ -383,6 +383,85 @@ export interface RecommendedNextStep { rationale: string } +/** + * "What's changed" — the trend-focused act of the report. Pre-computed + * deltas between the latest run/period and the prior one. Renderers must + * not recompute these from `citationsTrend` etc. — read them directly. + * + * `enoughHistory: false` means there is not enough run/trend data to compute + * meaningful deltas; renderers should fall back to a baseline message. + */ +export interface ReportRateDelta { + /** Current value (0..100 for rates, raw count otherwise). */ + current: number + /** Prior value compared against. */ + prior: number + /** Absolute delta (current − prior). Negative = decrease. */ + deltaAbs: number + /** + * Direction tag for tone mapping. 'flat' when |deltaAbs| < 0.5 for rates + * or 0 for counts; 'unknown' is reserved for prior-undefined cases that + * the parent shape captures with a null instead. + */ + direction: 'up' | 'down' | 'flat' +} + +export interface ReportProviderMovement { + provider: string + current: number + prior: number + deltaAbs: number + direction: 'up' | 'down' | 'flat' +} + +export interface WhatsChangedSection { + /** + * False when there's no prior run (or fewer than the trend baseline), + * meaning all per-metric deltas will be null. Renderers use this to swap + * in a "establishing baseline" fallback rather than rendering empty + * delta tiles. + */ + enoughHistory: boolean + /** + * One-sentence narrative summary suitable as a section subtitle. + * Always present — even on baseline, narrates whatever signal exists. + */ + headline: string + /** Citation rate delta vs the prior completed run. Null when no prior run. */ + citationRate: ReportRateDelta | null + /** Mention rate delta vs the prior completed run. Null when no prior run. */ + mentionRate: ReportRateDelta | null + /** Cited query count delta vs the prior completed run. Null when no prior run. */ + citedQueryCount: ReportRateDelta | null + /** + * GSC clicks delta — last 14 days of `gsc.trend` vs the 14 days before + * that. Null when GSC isn't connected or fewer than 28 trend points exist. + */ + gscClicksDelta: ReportRateDelta | null + /** + * AI referral sessions delta — last 14 days of `aiReferrals.trend` vs the + * 14 days before that. Null when AI referrals aren't tracked or fewer + * than 28 trend points exist. + */ + aiReferralsDelta: ReportRateDelta | null + /** + * Per-provider citation rate movements (latest run vs prior run). Empty + * when no prior run. Sorted by |deltaAbs| desc — providers with the + * biggest swing first. + */ + providerMovements: ReportProviderMovement[] + /** + * Top wins this period — gains surfaced by the intelligence engine. + * Capped at 5; sourced from `insights` filtered to `type: 'gain'`. + */ + wins: ReportInsight[] + /** + * Top regressions this period — citations or mentions lost. Capped at 5; + * sourced from `insights` filtered to `type: 'regression'`. + */ + regressions: ReportInsight[] +} + export type ReportAudience = 'agency' | 'client' export type ReportActionAudience = ReportAudience | 'both' export type ReportActionHorizon = 'immediate' | 'short-term' | 'medium-term' @@ -503,6 +582,11 @@ export interface ProjectReportDto { aiReferrals: AiReferralSection | null indexingHealth: IndexingHealthSection | null citationsTrend: CitationsTrendPoint[] + /** + * Trend-focused "what's changed" summary for the report's act 2. Always + * present; renderers gate empty/baseline states via `enoughHistory`. + */ + whatsChanged: WhatsChangedSection insights: ReportInsight[] recommendedNextSteps: RecommendedNextStep[] /** Canonical structured actions shared by the client and agency render modes. */
Severity