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
161 changes: 160 additions & 1 deletion tools/web-ui/public/dashboard-enhancer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = `<p class="hint">Couldn\u2019t load competitor signals: ${esc(err.message || err)}</p>`;
if (meta) meta.textContent = '';
return;
}

const groups = (data && Array.isArray(data.groups)) ? data.groups : [];

if (!groups.length) {
host.innerHTML = `<p class="hint">No reports yet \u2014 run a scan to surface competitor signals.</p>`;
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 = `
<p class="hint">No competitor mentions found in your scan reports.</p>
<p class="hint" style="margin-top:0.25rem">Enable <strong>Competitor tracking</strong> in your config and run a scan to surface signals.</p>
`;
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 '<span class="hint">no classified mentions</span>';
return SENT_ORDER
.filter((k) => sentiments[k] > 0)
.map((k) => `<span class="comp-sent-dot" title="${k}: ${sentiments[k]}">${SENT_DOT[k] || '·'} ${sentiments[k]}</span>`)
.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 ? `<span class="hint">${sources} source${sources === 1 ? '' : 's'}</span>` : '';
}

// 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 `
<section class="comp-group comp-group-empty">
<header class="comp-group-head">
<code class="comp-slug">${esc(g.slug)}</code>
<span class="hint comp-no-data">No competitor mentions in latest scan</span>
</header>
</section>`;
}

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 `
<tr class="comp-row">
<td class="comp-name">${esc(name)}</td>
<td class="comp-mentions">${cdata.mentions || 0}</td>
<td class="comp-sentiment">${sentimentBar(cdata.sentiments)}</td>
<td class="comp-sources hint">${esc(sources.join(', '))}</td>
</tr>`;
})
.join('');

const failureBanner = failures.length
? `<p class="hint comp-partial-warning">\u26a0\ufe0f Partial coverage \u2014 ${failures.length} source${failures.length === 1 ? '' : 's'} unavailable (${esc(failures.map((f) => f.source).join(', '))})</p>`
: '';

const staleBanner = isStale
? `<p class="hint comp-stale-warning">Showing competitor data from an earlier scan (${esc(g.reportName)})</p>`
: '';

const linkBtn = `<button type="button" class="link-btn comp-open-btn" data-report="${esc(g.reportName)}">Open report \u2192</button>`;

return `
<section class="comp-group">
<header class="comp-group-head">
<code class="comp-slug">${esc(g.slug)}</code>
<span class="hint">${agg.mentions || 0} mention${(agg.mentions || 0) === 1 ? '' : 's'}</span>
${linkBtn}
</header>
${staleBanner}
${failureBanner}
<table class="comp-table">
<thead>
<tr>
<th>Competitor</th>
<th>Mentions</th>
<th>Sentiment</th>
<th>Sources</th>
</tr>
</thead>
<tbody>${competitorRows}</tbody>
</table>
</section>`;
});

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;
Expand Down
9 changes: 9 additions & 0 deletions tools/web-ui/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,15 @@ <h3>Source health</h3>
</div>
</div>

<!-- Competitor signals — visually distinct from primary sentiment -->
<div class="card dash-competitor-card" id="dash-competitor-sentiment-card">
<div class="dash-card-head">
<h3>Competitor signals</h3>
<span class="hint" id="dash-competitor-sentiment-meta"></span>
</div>
<div id="dash-competitor-sentiment"><span class="skeleton" style="width:65%"></span><span class="skeleton" style="width:45%"></span></div>
</div>

<!-- 5. Events & next steps -->
<h3 class="dash-section-head">Events &amp; next steps</h3>
<div class="cards dash-row-2">
Expand Down
56 changes: 56 additions & 0 deletions tools/web-ui/public/theme-scout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
100 changes: 100 additions & 0 deletions tools/web-ui/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading