diff --git a/tools/web-ui/public/dashboard-enhancer.js b/tools/web-ui/public/dashboard-enhancer.js
index c0f3280..2c80196 100644
--- a/tools/web-ui/public/dashboard-enhancer.js
+++ b/tools/web-ui/public/dashboard-enhancer.js
@@ -87,6 +87,7 @@
renderSuggestions({ configs: configList, reports: reportList, social });
renderLatestReport(reportList);
loadCfpEvents(configList);
+ loadCompetitorSentiment();
// Social activity / sentiment / creators / source health are owned by
// intel.js. Do not add loaders for #dash-social-activity, #dash-sentiment,
// #dash-creators, or #dash-source-health here โ two writers on the same
@@ -342,7 +343,165 @@
function renderStack() { /* removed โ replaced with renderSuggestions */ }
- // Friendly, actionable nudges built from state we already have.
+ // --- Competitor signals card ---
+ // Fetches /api/competitor-sentiment and renders a per-competitor breakdown
+ // showing mention volume, sentiment distribution, and switching signals.
+ // Kept visually and semantically separate from the primary-product sentiment
+ // card (#dash-sentiment) so the two are never conflated.
+ async function loadCompetitorSentiment() {
+ const host = $('dash-competitor-sentiment');
+ const meta = $('dash-competitor-sentiment-meta');
+ if (!host) return;
+
+ let data;
+ try {
+ data = await fetchJSON('/api/competitor-sentiment');
+ } catch (err) {
+ host.innerHTML = `
Couldn\u2019t load competitor signals: ${esc(err.message || err)}
`;
+ if (meta) meta.textContent = '';
+ return;
+ }
+
+ const groups = (data && Array.isArray(data.groups)) ? data.groups : [];
+
+ if (!groups.length) {
+ host.innerHTML = `No reports yet \u2014 run a scan to surface competitor signals.
`;
+ if (meta) meta.textContent = '';
+ return;
+ }
+
+ // Check whether any group has competitor tracking data at all.
+ const anyWithData = groups.some((g) => g.hasData);
+ const totalMentions = groups.reduce((n, g) => n + (g.competitorAggregates?.mentions || 0), 0);
+
+ if (meta) {
+ meta.textContent = anyWithData
+ ? `${totalMentions} mention${totalMentions === 1 ? '' : 's'} across ${groups.length} subject${groups.length === 1 ? '' : 's'}`
+ : '';
+ }
+
+ if (!anyWithData) {
+ // All subjects either have no reports or have no competitor mentions.
+ host.innerHTML = `
+ No competitor mentions found in your scan reports.
+ Enable Competitor tracking in your config and run a scan to surface signals.
+ `;
+ return;
+ }
+
+ const SENT_DOT = { positive: '๐ข', neutral: 'โช', mixed: '๐ ', negative: '๐ด', unknown: 'ยท' };
+ const SENT_ORDER = ['positive', 'neutral', 'mixed', 'negative', 'unknown'];
+
+ function sentimentBar(sentiments) {
+ if (!sentiments) return '';
+ const total = SENT_ORDER.reduce((n, k) => n + (sentiments[k] || 0), 0);
+ if (!total) return 'no classified mentions ';
+ return SENT_ORDER
+ .filter((k) => sentiments[k] > 0)
+ .map((k) => `${SENT_DOT[k] || 'ยท'} ${sentiments[k]} `)
+ .join(' ');
+ }
+
+ function switchingBadge(byCompetitor) {
+ // Switching signals surface as items whose switchingDirection is
+ // stored in the competitorMentions array (not in aggregates). The
+ // aggregates don't track switching direction, so we indicate source
+ // coverage instead as a proxy for signal richness.
+ const sources = Object.keys(byCompetitor).length;
+ return sources ? `${sources} source${sources === 1 ? '' : 's'} ` : '';
+ }
+
+ // Navigate to Reports โ Competitors tab for the given report name.
+ function openCompetitorsTab(reportName) {
+ const navBtn = document.querySelector('nav button[data-view="reports"]');
+ if (navBtn) navBtn.click();
+ setTimeout(() => {
+ const tabBtn = document.querySelector('#reports-tabs button[data-tab="competitors"]');
+ if (tabBtn) tabBtn.click();
+ if (reportName) {
+ setTimeout(() => {
+ const row = document.querySelector(`#reports-list li[data-name="${CSS.escape(reportName)}"]`);
+ if (row) row.click();
+ }, 120);
+ }
+ }, 120);
+ }
+
+ const sections = groups.map((g) => {
+ if (!g.hasData) {
+ return `
+
+
+ ${esc(g.slug)}
+ No competitor mentions in latest scan
+
+ `;
+ }
+
+ const agg = g.competitorAggregates || {};
+ const byCompetitor = agg.byCompetitor || {};
+ const failures = (g.competitorSourceFailures || []);
+ const isStale = g.reportName !== g.newestReport;
+
+ const competitorRows = Object.entries(byCompetitor)
+ .sort(([, a], [, b]) => (b.mentions || 0) - (a.mentions || 0))
+ .map(([name, cdata]) => {
+ const sources = Object.keys(cdata.bySource || {});
+ return `
+
+ ${esc(name)}
+ ${cdata.mentions || 0}
+ ${sentimentBar(cdata.sentiments)}
+ ${esc(sources.join(', '))}
+ `;
+ })
+ .join('');
+
+ const failureBanner = failures.length
+ ? `\u26a0\ufe0f Partial coverage \u2014 ${failures.length} source${failures.length === 1 ? '' : 's'} unavailable (${esc(failures.map((f) => f.source).join(', '))})
`
+ : '';
+
+ const staleBanner = isStale
+ ? `Showing competitor data from an earlier scan (${esc(g.reportName)})
`
+ : '';
+
+ const linkBtn = `Open report \u2192 `;
+
+ return `
+
+
+ ${esc(g.slug)}
+ ${agg.mentions || 0} mention${(agg.mentions || 0) === 1 ? '' : 's'}
+ ${linkBtn}
+
+ ${staleBanner}
+ ${failureBanner}
+
+
+
+ Competitor
+ Mentions
+ Sentiment
+ Sources
+
+
+ ${competitorRows}
+
+ `;
+ });
+
+ host.innerHTML = sections.join('');
+
+ // Wire the "Open report โ" buttons to the Reports โ Competitors tab.
+ host.querySelectorAll('.comp-open-btn[data-report]').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ openCompetitorsTab(btn.dataset.report);
+ });
+ });
+ }
+
+
function renderSuggestions({ configs, reports, social }) {
const ul = $('dash-suggestions');
if (!ul) return;
diff --git a/tools/web-ui/public/index.html b/tools/web-ui/public/index.html
index 6557801..0da65b4 100644
--- a/tools/web-ui/public/index.html
+++ b/tools/web-ui/public/index.html
@@ -670,6 +670,15 @@ Source health
+
+
+
+
Competitor signals
+
+
+
+
+
Events & next steps
diff --git a/tools/web-ui/public/theme-scout.css b/tools/web-ui/public/theme-scout.css
index 19cec68..7703897 100644
--- a/tools/web-ui/public/theme-scout.css
+++ b/tools/web-ui/public/theme-scout.css
@@ -180,3 +180,59 @@ a:focus-visible, button:focus-visible, [tabindex]:focus-visible {
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent) !important;
border-radius: var(--scout-r-control);
}
+
+/* ---------- 16. Competitor signals card ---------- */
+
+/* Distinguish the competitor card from the primary sentiment card
+ by using a subtly different border and a distinct section background. */
+.dash-competitor-card {
+ border-left: 3px solid var(--accent-soft, #334);
+ margin-top: 1rem;
+}
+
+.comp-group {
+ margin: 0.75rem 0;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--border);
+}
+.comp-group:last-child { border-bottom: none; padding-bottom: 0; }
+.comp-group-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.4rem;
+}
+.comp-slug { font-size: 0.875rem; }
+.comp-no-data { font-style: italic; }
+.comp-partial-warning, .comp-stale-warning {
+ font-size: 0.75rem;
+ margin: 0.2rem 0;
+ color: var(--muted, #888);
+}
+
+.comp-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8125rem;
+ margin-top: 0.4rem;
+}
+.comp-table th {
+ text-align: left;
+ font-weight: 500;
+ padding: 3px 6px 3px 0;
+ border-bottom: 1px solid var(--border);
+ color: var(--muted, #888);
+ white-space: nowrap;
+}
+.comp-table td {
+ padding: 4px 6px 4px 0;
+ border-bottom: 1px solid var(--border);
+ vertical-align: top;
+}
+.comp-table tbody tr:last-child td { border-bottom: none; }
+.comp-name { font-weight: 500; }
+.comp-mentions { text-align: right; padding-right: 1rem; white-space: nowrap; }
+.comp-sentiment { white-space: nowrap; }
+.comp-sent-dot { margin-right: 0.35rem; }
+.comp-sources { max-width: 120px; word-break: break-word; }
diff --git a/tools/web-ui/server.js b/tools/web-ui/server.js
index c839cc3..c0ac0d5 100644
--- a/tools/web-ui/server.js
+++ b/tools/web-ui/server.js
@@ -872,9 +872,66 @@ async function readMarkdown(dir, name) {
}
const file = safeJoin(dir, name);
const raw = await fs.readFile(file, 'utf8');
+
+ // If the markdown already has a Competitor & Market Signals section, return
+ // it as-is. Otherwise, try to synthesise that section from the JSON sidecar
+ // so the Reports โ Competitors tab shows structured data even when the
+ // agent wrote the sidecar but didn't embed the section in the markdown.
+ const hasCompetitorSection = /^#{2,3}\s+(competitor|competitive)\b/im.test(raw);
+ if (!hasCompetitorSection) {
+ try {
+ const jsonPath = safeJoin(dir, name.replace(/\.md$/, '.json'));
+ const jsonRaw = await fs.readFile(jsonPath, 'utf8');
+ const sidecar = JSON.parse(jsonRaw);
+ const agg = sidecar && sidecar.competitor_aggregates;
+ if (agg && (agg.mentions || 0) > 0) {
+ const appendix = buildCompetitorSectionMd(agg, sidecar.competitor_source_failures || []);
+ if (appendix) {
+ return { name, raw, html: marked.parse(raw + '\n\n' + appendix) };
+ }
+ }
+ } catch {
+ // JSON sidecar missing or malformed โ fall through to plain markdown.
+ }
+ }
+
return { name, raw, html: marked.parse(raw) };
}
+// Generate a minimal markdown `## Competitor & Market Signals` section from
+// structured competitorAggregates so it can be extracted by the Competitors tab
+// and displayed in the full report view when the markdown doesn't have one.
+function buildCompetitorSectionMd(aggregates, sourceFailures) {
+ if (!aggregates || !aggregates.mentions) return '';
+ const byCompetitor = aggregates.byCompetitor || {};
+ const entries = Object.entries(byCompetitor)
+ .sort(([, a], [, b]) => (b.mentions || 0) - (a.mentions || 0));
+ if (!entries.length) return '';
+
+ const SENT_LABELS = {
+ positive: '๐ข Positive',
+ neutral: 'โช Neutral',
+ mixed: '๐ Mixed',
+ negative: '๐ด Negative',
+ unknown: 'ยท Unknown',
+ };
+
+ const rows = entries.map(([name, data]) => {
+ const sentiments = data.sentiments || {};
+ const sentParts = ['positive', 'neutral', 'mixed', 'negative', 'unknown']
+ .filter((k) => sentiments[k] > 0)
+ .map((k) => `${SENT_LABELS[k]}: ${sentiments[k]}`);
+ const sources = Object.keys(data.bySource || {}).join(', ') || 'โ';
+ return `- **${name}**: ${data.mentions || 0} mention${(data.mentions || 0) === 1 ? '' : 's'} โ ${sentParts.join(', ') || 'unclassified'} ยท Sources: ${sources}`;
+ });
+
+ const failureNote = (sourceFailures || []).length
+ ? `\n\n> โ ๏ธ Partial coverage โ ${sourceFailures.length} source${sourceFailures.length === 1 ? '' : 's'} unavailable: ${sourceFailures.map((f) => f.source).join(', ')}`
+ : '';
+
+ return `## Competitor & Market Signals\n\n${rows.join('\n')}${failureNote}`;
+}
+
// Parse a .env-style string into { key, value } entries. Preserves insertion order.
// Values may be unquoted, "double", or 'single' quoted. Comments/blank lines are ignored for the key list.
function parseEnv(raw) {
@@ -2753,6 +2810,8 @@ async function _buildIndex() {
convoCount: parsed.conversations.length,
sentimentTotals: parsed.sentimentTotals,
skippedSources: parsed.skippedSources,
+ competitorAggregates: parsed.competitorAggregates,
+ competitorSourceFailures: parsed.competitorSourceFailures,
});
for (const it of parsed.items)
items.push({
@@ -3560,6 +3619,47 @@ app.get('/api/sentiment-summary', async (_req, res) => {
}
});
+// Competitor sentiment summary โ per-slug aggregates from the most recent
+// scan that carried competitor tracking data. Used by the dashboard competitor
+// signals card. Returns one group per configured subject; each group includes
+// the full competitorAggregates from the freshest scan with data plus any
+// source failures so the UI can show partial-coverage states.
+app.get('/api/competitor-sentiment', async (_req, res) => {
+ try {
+ const idx = await getIndex();
+ const bySlug = new Map();
+ for (const r of idx.reports) {
+ const list = bySlug.get(r.slug) || [];
+ list.push(r);
+ bySlug.set(r.slug, list);
+ }
+ const groups = [];
+ for (const [slug, list] of bySlug.entries()) {
+ list.sort((a, b) => (b.mtime || '').localeCompare(a.mtime || ''));
+ const newest = list[0];
+ // Prefer the most recent report that actually has competitor mentions so
+ // a later items-only scan doesn't blank the competitor card.
+ const withData = list.filter((r) => (r.competitorAggregates?.mentions || 0) > 0);
+ const latest = withData[0] || newest;
+ const emptyAggregates = { mentions: 0, byCompetitor: {}, bySource: {} };
+ groups.push({
+ slug,
+ reportName: latest.name,
+ generatedAt: latest.generatedAt,
+ // Also surface the true newest report name so the UI can flag when
+ // the competitor data shown is from an earlier scan.
+ newestReport: newest.name,
+ competitorAggregates: latest.competitorAggregates || emptyAggregates,
+ competitorSourceFailures: latest.competitorSourceFailures || [],
+ hasData: (latest.competitorAggregates?.mentions || 0) > 0,
+ });
+ }
+ res.json({ groups, builtAt: idx.builtAt });
+ } catch (err) {
+ res.status(500).json({ error: String(err.message || err) });
+ }
+});
+
app.get('/api/search', async (req, res) => {
try {
const q = String(req.query.q || '').trim();
diff --git a/tools/web-ui/test/report-index.test.js b/tools/web-ui/test/report-index.test.js
index 53862b1..352995d 100644
--- a/tools/web-ui/test/report-index.test.js
+++ b/tools/web-ui/test/report-index.test.js
@@ -311,4 +311,196 @@ test('loadReport backfills empty conversation summaries from .cached-bodies.json
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
-});
\ No newline at end of file
+});
+test('parseReportFromJson: competitor mentions do not affect primary sentiment totals (reconciliation)', () => {
+ // A conversation item that belongs to the primary product should count in
+ // sentimentTotals; a competitor mention row must not.
+ const parsed = parseReportFromJson({
+ generated_at: '2026-07-20',
+ items: [],
+ // One community conversation โ positive sentiment.
+ conversations: [
+ {
+ section: 'conversations',
+ date: '2026-07-20',
+ title: 'Azure Cosmos DB rocks',
+ url: 'https://reddit.com/r/databases/comments/2/primary',
+ author: { display_name: 'CommunityUser', platform: 'reddit' },
+ sentiment: 'positive',
+ sentiment_confidence: 'high',
+ },
+ ],
+ // One competitor mention with negative sentiment toward the competitor.
+ competitor_mentions: [
+ {
+ competitor: 'Amazon DynamoDB',
+ competitorSentiment: 'negative',
+ competitorSentimentConfidence: 'high',
+ platform: 'reddit',
+ timestamp: '2026-07-20T12:00:00Z',
+ url: 'https://reddit.com/r/databases/comments/3/comp',
+ switchingDirection: 'competitor_to_primary',
+ },
+ ],
+ competitor_aggregates: {
+ mentions: 1,
+ byCompetitor: {
+ 'Amazon DynamoDB': {
+ mentions: 1,
+ sentiments: { positive: 0, neutral: 0, negative: 1, mixed: 0, unknown: 0 },
+ bySource: { reddit: 1 },
+ },
+ },
+ bySource: { reddit: { mentions: 1 } },
+ },
+ }, '2026-07-20-1200-test-product-content.md');
+
+ // Primary sentiment totals should only count the community conversation.
+ assert.deepEqual(parsed.sentimentTotals, {
+ positive: 1,
+ neutral: 0,
+ negative: 0,
+ mixed: 0,
+ unknown: 0,
+ });
+ // Competitor aggregates are exposed but do not leak into primary totals.
+ assert.equal(parsed.competitorAggregates.mentions, 1);
+ assert.equal(
+ parsed.competitorAggregates.byCompetitor['Amazon DynamoDB'].sentiments.negative,
+ 1
+ );
+});
+
+test('parseReportFromJson: partial competitor source failures are exposed', () => {
+ const parsed = parseReportFromJson({
+ generated_at: '2026-07-20',
+ items: [],
+ competitor_mentions: [],
+ competitor_aggregates: { mentions: 0, byCompetitor: {}, bySource: {} },
+ competitor_source_failures: [
+ { source: 'x', error: 'rate limited' },
+ { source: 'linkedin', error: 'login required' },
+ ],
+ }, '2026-07-20-1200-test-product-content.md');
+
+ assert.equal(parsed.competitorSourceFailures.length, 2);
+ assert.equal(parsed.competitorSourceFailures[0].source, 'x');
+ assert.equal(parsed.competitorSourceFailures[1].source, 'linkedin');
+ // Despite source failures, primary sentiment totals are zero (no conversations).
+ assert.deepEqual(parsed.sentimentTotals, {
+ positive: 0,
+ neutral: 0,
+ negative: 0,
+ mixed: 0,
+ unknown: 0,
+ });
+});
+
+test('parseReportFromJson: multiple competitors aggregated independently', () => {
+ const parsed = parseReportFromJson({
+ generated_at: '2026-07-20',
+ items: [],
+ competitor_mentions: [
+ {
+ competitor: 'Amazon DynamoDB',
+ competitorSentiment: 'negative',
+ platform: 'reddit',
+ url: 'https://reddit.com/r/databases/comments/1/a',
+ },
+ {
+ competitor: 'MongoDB Atlas',
+ competitorSentiment: 'positive',
+ platform: 'hackernews',
+ url: 'https://news.ycombinator.com/item?id=1',
+ },
+ ],
+ competitor_aggregates: {
+ mentions: 2,
+ byCompetitor: {
+ 'Amazon DynamoDB': {
+ mentions: 1,
+ sentiments: { positive: 0, neutral: 0, negative: 1, mixed: 0, unknown: 0 },
+ bySource: { reddit: 1 },
+ },
+ 'MongoDB Atlas': {
+ mentions: 1,
+ sentiments: { positive: 1, neutral: 0, negative: 0, mixed: 0, unknown: 0 },
+ bySource: { hackernews: 1 },
+ },
+ },
+ bySource: {
+ reddit: { mentions: 1 },
+ hackernews: { mentions: 1 },
+ },
+ },
+ }, '2026-07-20-1200-test-product-content.md');
+
+ const byComp = parsed.competitorAggregates.byCompetitor;
+ assert.equal(parsed.competitorAggregates.mentions, 2);
+ assert.equal(byComp['Amazon DynamoDB'].sentiments.negative, 1);
+ assert.equal(byComp['MongoDB Atlas'].sentiments.positive, 1);
+ // Each competitor's data is independent.
+ assert.equal(byComp['Amazon DynamoDB'].mentions, 1);
+ assert.equal(byComp['MongoDB Atlas'].mentions, 1);
+ // Primary sentiment totals are still empty (no conversations).
+ assert.deepEqual(parsed.sentimentTotals, {
+ positive: 0,
+ neutral: 0,
+ negative: 0,
+ mixed: 0,
+ unknown: 0,
+ });
+});
+
+test('loadReport: competitor aggregates survive round-trip through JSON sidecar', async () => {
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cs-comp-agg-'));
+ try {
+ const json = {
+ generated_at: '2026-07-20',
+ items: [],
+ competitor_mentions: [
+ {
+ competitor: 'Amazon DynamoDB',
+ competitorSentiment: 'mixed',
+ competitorSentimentConfidence: 'high',
+ platform: 'reddit',
+ url: 'https://reddit.com/r/databases/comments/4/roundtrip',
+ switchingDirection: 'none',
+ },
+ ],
+ competitor_aggregates: {
+ mentions: 1,
+ byCompetitor: {
+ 'Amazon DynamoDB': {
+ mentions: 1,
+ sentiments: { positive: 0, neutral: 0, negative: 0, mixed: 1, unknown: 0 },
+ bySource: { reddit: 1 },
+ },
+ },
+ bySource: { reddit: { mentions: 1 } },
+ },
+ competitor_source_failures: [{ source: 'x', error: 'unavailable' }],
+ };
+ const fileName = '2026-07-20-1400-test-product-content.md';
+ await fs.writeFile(path.join(tmp, fileName.replace(/\.md$/, '.json')), JSON.stringify(json));
+
+ const parsed = await loadReport(tmp, fileName);
+ assert.equal(parsed.competitorMentions.length, 1);
+ assert.equal(parsed.competitorMentions[0].competitor, 'Amazon DynamoDB');
+ assert.equal(parsed.competitorMentions[0].competitorSentiment, 'mixed');
+ assert.equal(parsed.competitorAggregates.mentions, 1);
+ assert.equal(parsed.competitorAggregates.byCompetitor['Amazon DynamoDB'].sentiments.mixed, 1);
+ assert.equal(parsed.competitorSourceFailures.length, 1);
+ assert.equal(parsed.competitorSourceFailures[0].source, 'x');
+ // Primary product sentiment totals must be unaffected.
+ assert.deepEqual(parsed.sentimentTotals, {
+ positive: 0,
+ neutral: 0,
+ negative: 0,
+ mixed: 0,
+ unknown: 0,
+ });
+ } finally {
+ await fs.rm(tmp, { recursive: true, force: true });
+ }
+});