Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
798 changes: 578 additions & 220 deletions apps/web/src/pages/ReportPage.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "canonry",
"private": true,
"version": "4.8.0",
"version": "4.10.1",
"type": "module",
"packageManager": "[email protected]",
"scripts": {
Expand Down
291 changes: 165 additions & 126 deletions packages/api-routes/src/report-renderer.ts

Large diffs are not rendered by default.

197 changes: 180 additions & 17 deletions packages/api-routes/src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -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',
})
}
Expand Down Expand Up @@ -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',
})
}
Expand All @@ -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.',
Expand All @@ -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.'
}
}
Expand All @@ -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.')
Expand All @@ -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
Expand All @@ -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}`),
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1424,6 +1586,7 @@ function buildProjectReport(db: DatabaseClient, projectName: string): ProjectRep
aiReferrals: aiReferralsSection,
indexingHealth: indexingHealthSection,
citationsTrend,
whatsChanged,
insights: insightList,
recommendedNextSteps,
actionPlan,
Expand Down
30 changes: 27 additions & 3 deletions packages/api-routes/test/report-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -827,7 +851,7 @@ describe('renderReportHtml', () => {
]
const html = renderReportHtml(report)
const block = html.split('id="citations-trend"')[1]?.split('</section>')[0] ?? ''
expect(block.toLowerCase()).toContain('establishing baseline')
expect(block.toLowerCase()).toContain('building baseline')
expect(block).not.toContain('<svg')
})

Expand All @@ -842,7 +866,7 @@ describe('renderReportHtml', () => {
const html = renderReportHtml(report)
const block = html.split('id="citations-trend"')[1]?.split('</section>')[0] ?? ''
expect(block).toContain('<svg')
expect(block.toLowerCase()).not.toContain('establishing baseline')
expect(block.toLowerCase()).not.toContain('building baseline')
})
})

Expand Down
Loading
Loading