Skip to content
Draft
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
167 changes: 167 additions & 0 deletions apps/web/src/build-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import type {
} from './api.js'
import type {
AffectedPhrase,
BrandHealthVm,
CitationInsightVm,
CitationState,
CrossSignalInsightVm,
EvidenceHistoryScope,
ModelTransitionVm,
CompetitorVm,
Expand All @@ -23,6 +25,7 @@ import type {
RunHistoryPoint,
RunListItemVm,
ScoreSummaryVm,
SocialSparklineVm,
} from './view-models.js'

function toProjectDto(p: ApiProject): ProjectDto {
Expand Down Expand Up @@ -627,6 +630,24 @@ function runStatusSummary(projectRuns: ApiRun[]): ScoreSummaryVm {
}
}

/**
* Optional social signals for a project. When present, enables brand health
* and cross-signal insight generation. All fields are read-only inputs derived
* from a social monitoring service; this package only consumes them.
*/
export interface SocialData {
/** 7-day daily mention counts (oldest → newest, length must be 7). */
dailyMentionCounts: number[]
/** Total engagement (likes + shares + comments) over the same window. */
totalEngagement: number
/** Number of mentions that link to the project's canonical domain. */
mentionsWithCanonicalLink: number
/** Total mention count over the 7-day window. */
totalMentions: number
/** Sentiment breakdown across all mentions. */
sentiment: { positive: number; neutral: number; negative: number }
}

export interface ProjectData {
project: ApiProject
runs: ApiRun[]
Expand All @@ -635,6 +656,134 @@ export interface ProjectData {
timeline: ApiTimelineEntry[]
latestRunDetail: ApiRunDetail | null
previousRunDetail: ApiRunDetail | null
/** Optional social monitoring data. Omit when not yet connected. */
socialData?: SocialData | null
}

// ── Social signal helpers ────────────────────────────────────────────────────

function buildSocialSparkline(social: SocialData): SocialSparklineVm {
if (social.dailyMentionCounts.length !== 7) {
throw new Error(`dailyMentionCounts must have exactly 7 elements, got ${social.dailyMentionCounts.length}`)
}
const counts = social.dailyMentionCounts
const first = counts[0] ?? 0
const last = counts[counts.length - 1] ?? 0
return {
counts,
delta: last - first,
sentimentSummary: social.sentiment,
}
}

function computeSentimentScore(sentiment: SocialData['sentiment']): number {
const total = sentiment.positive + sentiment.neutral + sentiment.negative
if (total === 0) return 0
return Math.round((sentiment.positive / total) * 100)
}

function computeDomainLinkRate(social: SocialData): number {
if (social.totalMentions === 0) return 0
const rate = (social.mentionsWithCanonicalLink / social.totalMentions) * 100
return Math.min(100, Math.round(rate))
}

function computeCompositeScore(aiScore: number, sentimentScore: number, domainLinkRate: number): number {
// Weighted blend: AI visibility 50%, sentiment 30%, domain link rate 20%
return Math.round(aiScore * 0.5 + sentimentScore * 0.3 + domainLinkRate * 0.2)
}

function buildBrandHealth(aiVisibilityScore: number, social: SocialData): BrandHealthVm {
const sentimentScore = computeSentimentScore(social.sentiment)
const domainLinkRate = computeDomainLinkRate(social)
const compositeScore = computeCompositeScore(aiVisibilityScore, sentimentScore, domainLinkRate)

return {
aiVisibilityScore,
aiVisibilityTone: scoreTone(aiVisibilityScore),
socialReach: social.totalEngagement,
sentimentScore,
sentimentTone: scoreTone(sentimentScore),
domainLinkRate,
domainLinkTone: scoreTone(domainLinkRate),
compositeScore,
compositeTone: scoreTone(compositeScore),
}
}

/** Minimum mentions considered "active social discussion" for cross-signal insights. */
const LOW_SOCIAL_THRESHOLD = 10
/** Minimum mentions to qualify as meaningful social traction vs AI citation gap. */
const SOCIAL_TRACTION_THRESHOLD = 20

/**
* Build cross-signal insights by comparing AI citation data against social
* discussion trends. Generates observations such as high-AI / low-social gaps
* or keywords with social traction but no AI visibility.
*/
export function buildCrossSignalInsights(
evidence: CitationInsightVm[],
social: SocialData | null | undefined,
): CrossSignalInsightVm[] {
const insights: CrossSignalInsightVm[] = []

if (!social) return insights

// Derive totalMentions from the daily array to ensure consistency with spike detection.
// Note: social.totalMentions is kept for display purposes but thresholds use the derived value.
const totalMentions = social.dailyMentionCounts.reduce((s, n) => s + n, 0)

// Insight: high AI visibility but low social discussion
const citedKeywords = evidence.filter(e => e.citationState === 'cited').map(e => e.keyword)
if (citedKeywords.length > 0 && totalMentions < LOW_SOCIAL_THRESHOLD) {
insights.push({
id: 'cross_high_ai_low_social',
tone: 'caution',
title: 'Strong AI visibility but low social discussion',
detail: `${citedKeywords.length} keyword${citedKeywords.length > 1 ? 's are' : ' is'} cited in AI answers but social mention volume is low (${totalMentions} mention${totalMentions !== 1 ? 's' : ''} in 7d). Consider seeding social content to amplify reach.`,
})
}

// Insight: negative sentiment spike
const { positive, negative, neutral } = social.sentiment
const totalSentiment = positive + negative + neutral
if (totalSentiment > 0 && negative / totalSentiment >= 0.4) {
insights.push({
id: 'cross_negative_sentiment',
tone: 'negative',
title: 'Elevated negative sentiment in social mentions',
detail: `${Math.round((negative / totalSentiment) * 100)}% of recent mentions carry negative sentiment. Review brand messaging and monitor for escalation.`,
})
}

// Insight: social mentions spiking (last day >> daily average)
const counts = social.dailyMentionCounts
if (counts.length >= 2) {
const latestDay = counts[counts.length - 1] ?? 0
const prior = counts.slice(0, -1)
const avg = prior.reduce((s, n) => s + n, 0) / prior.length
if (avg > 0 && latestDay >= avg * 2) {
insights.push({
id: 'cross_social_spike',
tone: 'positive',
title: 'Social mention spike detected',
detail: `Mentions jumped to ${latestDay} today vs a ${Math.round(avg)}/day average — a ${Math.round((latestDay / avg - 1) * 100)}% increase. Engage while the conversation is active.`,
})
}
}

// Insight: good social but no AI citations
const notCitedCount = evidence.filter(e => e.citationState === 'not-cited' || e.citationState === 'pending').length
if (notCitedCount > 0 && totalMentions >= SOCIAL_TRACTION_THRESHOLD) {
insights.push({
id: 'cross_social_no_ai',
tone: 'caution',
title: 'Social traction without AI citation',
detail: `${notCitedCount} keyword${notCitedCount > 1 ? 's have' : ' has'} no AI citation despite active social discussion (${totalMentions} mention${totalMentions !== 1 ? 's' : ''}). Optimise content for AI answer engines to convert social reach into AI visibility.`,
})
}

return insights
}

export function buildProjectCommandCenter(data: ProjectData): ProjectCommandCenterVm {
Expand Down Expand Up @@ -675,6 +824,11 @@ export function buildProjectCommandCenter(data: ProjectData): ProjectCommandCent
total,
}))

const brandHealth = data.socialData
? buildBrandHealth(kwVis.score, data.socialData)
: null
const crossSignalInsights = buildCrossSignalInsights(evidence, data.socialData)

return {
project: dto,
dateRangeLabel: 'All time',
Expand Down Expand Up @@ -705,6 +859,8 @@ export function buildProjectCommandCenter(data: ProjectData): ProjectCommandCent
runStatus: runStatusSummary(sortedRuns),
insights,
visibilityEvidence: evidence,
brandHealth,
crossSignalInsights,
competitors: data.competitors.map((c, i) => {
const citedKeywordSet = new Set<string>()
for (const snap of snapshots) {
Expand Down Expand Up @@ -760,6 +916,15 @@ export function buildPortfolioProject(data: ProjectData): PortfolioProjectVm {
triggerLabel: '',
}

const socialSparkline = data.socialData ? buildSocialSparkline(data.socialData) : null
const brandVisibilityScore = data.socialData
? computeCompositeScore(
kwVis.score,
computeSentimentScore(data.socialData.sentiment),
computeDomainLinkRate(data.socialData),
)
: null

return {
project: dto,
visibilityScore: kwVis.score,
Expand All @@ -770,6 +935,8 @@ export function buildPortfolioProject(data: ProjectData): PortfolioProjectVm {
: 'No runs completed yet.',
trend: [],
competitorPressureLabel: pressure.label,
socialSparkline,
brandVisibilityScore,
}
}

Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,8 @@ const baseProjectCommandCenters: ProjectCommandCenterVm[] = [
},
],
recentRuns: [runCitypointQueued, runCitypointVisibility],
brandHealth: null,
crossSignalInsights: [],
},
{
project: projects[1],
Expand Down Expand Up @@ -563,6 +565,8 @@ const baseProjectCommandCenters: ProjectCommandCenterVm[] = [
},
],
recentRuns: [runHarborVisibility],
brandHealth: null,
crossSignalInsights: [],
},
{
project: projects[2],
Expand Down Expand Up @@ -645,6 +649,8 @@ const baseProjectCommandCenters: ProjectCommandCenterVm[] = [
},
],
recentRuns: [runNorthstarVisibility],
brandHealth: null,
crossSignalInsights: [],
},
]

Expand All @@ -659,6 +665,8 @@ const baseDashboard: DashboardVm = {
insight: 'Lost emergency-intent citations after competitors refreshed availability pages.',
trend: [73, 71, 69, 66, 61],
competitorPressureLabel: 'High',
socialSparkline: null,
brandVisibilityScore: null,
},
{
project: projects[1],
Expand All @@ -668,6 +676,8 @@ const baseDashboard: DashboardVm = {
insight: 'Practice-area consolidation is stabilizing branded and informational prompts.',
trend: [68, 70, 71, 73, 74],
competitorPressureLabel: 'Moderate',
socialSparkline: null,
brandVisibilityScore: null,
},
{
project: projects[2],
Expand All @@ -677,6 +687,8 @@ const baseDashboard: DashboardVm = {
insight: 'Location pages are improving, but local treatment proof still trails competitors.',
trend: [52, 54, 55, 57, 58],
competitorPressureLabel: 'Moderate',
socialSparkline: null,
brandVisibilityScore: null,
},
],
attentionItems: [
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/view-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,43 @@ export interface RunListItemVm extends RunDto {
triggerLabel: string
}

export interface SocialSparklineVm {
/** 7-day daily mention counts (oldest to newest). */
counts: number[]
/** Net change from first to last day in the window. */
delta: number
/** Overall sentiment distribution across the 7d window. */
sentimentSummary: { positive: number; neutral: number; negative: number }
}

export interface BrandHealthVm {
/** AI citation rate — existing keyword visibility score (0-100). */
aiVisibilityScore: number
aiVisibilityTone: MetricTone
/** Total engagement (likes + shares + comments) across monitored platforms. */
socialReach: number
/** Percentage of mentions with positive sentiment (0-100). */
sentimentScore: number
sentimentTone: MetricTone
/** Percentage of social mentions that link to the canonical domain (0-100). */
domainLinkRate: number
domainLinkTone: MetricTone
/** Composite brand visibility combining AI + social signals (0-100). */
compositeScore: number
compositeTone: MetricTone
}

export interface CrossSignalInsightVm {
id: string
tone: MetricTone
title: string
detail: string
// TODO: populate when per-keyword cross-signal insights are implemented.
keyword?: string
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Style] keyword and platform are declared on CrossSignalInsightVm but none of the four insights.push(...) calls in buildCrossSignalInsights ever sets them. Consumers cannot rely on these fields being populated, and the dead fields add noise to the interface.

Either:

  • Remove them if there is no near-term plan to populate them, or
  • Open a follow-up issue and add a // TODO comment explaining when/how they will be used (e.g. for per-keyword or per-platform insights in a future iteration).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 18f30aa — added TODO comments on both keyword and platform fields in CrossSignalInsightVm to make the intent explicit: // TODO: populate when per-keyword/per-platform cross-signal insights are implemented.

// TODO: populate when per-platform cross-signal insights are implemented.
platform?: string
}

export interface PortfolioProjectVm {
project: ProjectDto
visibilityScore: number
Expand All @@ -63,6 +100,10 @@ export interface PortfolioProjectVm {
insight: string
trend: number[]
competitorPressureLabel: string
/** 7-day social mention sparkline. Null when no social data is available. */
socialSparkline: SocialSparklineVm | null
/** Composite brand visibility score combining AI + social (null if no data). */
brandVisibilityScore: number | null
}

export interface PortfolioOverviewVm {
Expand Down Expand Up @@ -156,6 +197,10 @@ export interface ProjectCommandCenterVm {
visibilityEvidence: CitationInsightVm[]
competitors: CompetitorVm[]
recentRuns: RunListItemVm[]
/** Combined brand health section (AI + social). Null when no social data available. */
brandHealth: BrandHealthVm | null
/** Cross-signal insights comparing AI visibility against social discussion trends. */
crossSignalInsights: CrossSignalInsightVm[]
}

export interface SetupHealthCheckVm {
Expand Down
Loading
Loading