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 = ``; + + return ` +
+
+ ${esc(g.slug)} + ${agg.mentions || 0} mention${(agg.mentions || 0) === 1 ? '' : 's'} + ${linkBtn} +
+ ${staleBanner} + ${failureBanner} + + + + + + + + + + ${competitorRows} +
CompetitorMentionsSentimentSources
+
`; + }); + + 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 }); + } +});