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
2 changes: 2 additions & 0 deletions .changeset/brave-emus-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
138 changes: 138 additions & 0 deletions server/public/admin-outreach.html
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ <h1>Proactive Outreach</h1>

<div class="tabs">
<button class="tab active" onclick="showTab('history')">History</button>
<button class="tab" onclick="showTab('analytics')">Analytics</button>
<button class="tab" onclick="showTab('variants')">A/B Variants</button>
<button class="tab" onclick="showTab('test-accounts')">Test Accounts</button>
</div>
Expand Down Expand Up @@ -439,6 +440,56 @@ <h1>Proactive Outreach</h1>
</div>
</div>

<!-- Analytics Tab -->
<div id="tab-analytics" class="tab-content">
<!-- Time-based stats -->
<div class="card" style="margin-bottom: 24px;">
<h3 style="margin-bottom: 16px; color: var(--color-text-heading);">Performance Over Time</h3>
<div class="stats-row" style="margin-bottom: 0;">
<div class="stat-card" style="box-shadow: none; background: var(--color-gray-50);">
<div class="label">Today</div>
<div class="value" id="time-stat-today-sent">—</div>
<div class="subtext"><span id="time-stat-today-responded">0</span> responded</div>
</div>
<div class="stat-card" style="box-shadow: none; background: var(--color-gray-50);">
<div class="label">This Week</div>
<div class="value" id="time-stat-week-sent">—</div>
<div class="subtext"><span id="time-stat-week-responded">0</span> responded</div>
</div>
<div class="stat-card" style="box-shadow: none; background: var(--color-gray-50);">
<div class="label">This Month</div>
<div class="value" id="time-stat-month-sent">—</div>
<div class="subtext"><span id="time-stat-month-responded">0</span> responded</div>
</div>
<div class="stat-card" style="box-shadow: none; background: var(--color-gray-50);">
<div class="label">All Time</div>
<div class="value" id="time-stat-total-sent">—</div>
<div class="subtext"><span id="time-stat-total-rate">0</span>% response rate</div>
</div>
</div>
</div>

<!-- Goal-level stats -->
<div class="card">
<h3 style="margin-bottom: 16px; color: var(--color-text-heading);">Response Rates by Goal</h3>
<div id="goal-stats-loading" class="loading">Loading goal statistics...</div>
<div id="goal-stats-empty" class="empty-state" style="display: none;">No outreach data yet.</div>
<table id="goal-stats-table" style="display: none;">
<thead>
<tr>
<th>Goal</th>
<th style="text-align: center;">Sent</th>
<th style="text-align: center;">Responded</th>
<th style="text-align: center;">Response Rate</th>
<th style="text-align: center;">Insights</th>
<th>Sentiment Breakdown</th>
</tr>
</thead>
<tbody id="goal-stats-body"></tbody>
</table>
</div>
</div>

<!-- A/B Variants Tab -->
<div id="tab-variants" class="tab-content">
<div class="section-header">
Expand Down Expand Up @@ -527,6 +578,7 @@ <h2 id="variant-modal-title">New Variant</h2>
loadHistory(),
loadVariants(),
loadTestAccounts(),
loadAnalytics(),
]);
}

Expand Down Expand Up @@ -916,6 +968,92 @@ <h2 id="variant-modal-title">New Variant</h2>
return div.innerHTML;
}

// Load analytics data (time stats + goal stats)
async function loadAnalytics() {
try {
const [timeResponse, goalResponse] = await Promise.all([
fetch('/api/admin/outreach/stats/time-series'),
fetch('/api/admin/outreach/stats/by-goal'),
]);

// Load time stats
if (timeResponse.ok) {
const timeStats = await timeResponse.json();
document.getElementById('time-stat-today-sent').textContent = timeStats.sent_today || 0;
document.getElementById('time-stat-today-responded').textContent = timeStats.responded_today || 0;
document.getElementById('time-stat-week-sent').textContent = timeStats.sent_this_week || 0;
document.getElementById('time-stat-week-responded').textContent = timeStats.responded_this_week || 0;
document.getElementById('time-stat-month-sent').textContent = timeStats.sent_this_month || 0;
document.getElementById('time-stat-month-responded').textContent = timeStats.responded_this_month || 0;
document.getElementById('time-stat-total-sent').textContent = timeStats.total_sent || 0;
document.getElementById('time-stat-total-rate').textContent = timeStats.overall_response_rate_pct || 0;
}

// Load goal stats
const loading = document.getElementById('goal-stats-loading');
const table = document.getElementById('goal-stats-table');
const empty = document.getElementById('goal-stats-empty');
const tbody = document.getElementById('goal-stats-body');

loading.style.display = 'none';

if (goalResponse.ok) {
const goalStats = await goalResponse.json();

if (goalStats.length === 0) {
empty.style.display = 'block';
return;
}

tbody.innerHTML = goalStats.map(goal => {
// Build sentiment bar
const total = goal.positive_responses + goal.neutral_responses + goal.negative_responses + goal.refusal_responses;
const sentimentBar = total > 0 ? `
<div style="display: flex; gap: 2px; height: 8px; min-width: 100px;">
${goal.positive_responses > 0 ? `<div style="flex: ${goal.positive_responses}; background: var(--color-success); border-radius: 2px;" title="${goal.positive_responses} positive"></div>` : ''}
${goal.neutral_responses > 0 ? `<div style="flex: ${goal.neutral_responses}; background: var(--color-gray-400); border-radius: 2px;" title="${goal.neutral_responses} neutral"></div>` : ''}
${goal.negative_responses > 0 ? `<div style="flex: ${goal.negative_responses}; background: var(--color-warning); border-radius: 2px;" title="${goal.negative_responses} negative"></div>` : ''}
${goal.refusal_responses > 0 ? `<div style="flex: ${goal.refusal_responses}; background: var(--color-danger); border-radius: 2px;" title="${goal.refusal_responses} refusal"></div>` : ''}
</div>
<div style="font-size: 10px; color: var(--color-text-muted); margin-top: 4px;">
${goal.positive_responses > 0 ? `<span style="color: var(--color-success);">${goal.positive_responses} +</span> ` : ''}
${goal.neutral_responses > 0 ? `<span style="color: var(--color-gray-600);">${goal.neutral_responses} ~</span> ` : ''}
${goal.negative_responses > 0 ? `<span style="color: var(--color-warning);">${goal.negative_responses} -</span> ` : ''}
${goal.refusal_responses > 0 ? `<span style="color: var(--color-danger);">${goal.refusal_responses} x</span>` : ''}
</div>
` : '<span style="color: var(--color-text-muted);">No responses</span>';

const responseRate = goal.response_rate_pct !== null ? `${goal.response_rate_pct}%` : '—';
const insightRate = goal.insight_conversion_rate_pct !== null ? `(${goal.insight_conversion_rate_pct}% conversion)` : '';

return `<tr>
<td>
<div style="font-weight: 500; color: var(--color-text-heading);">${escapeHtml(goal.goal_name)}</div>
${goal.is_enabled ? '' : '<span style="font-size: 10px; color: var(--color-text-muted);">Disabled</span>'}
</td>
<td style="text-align: center;">${goal.total_sent}</td>
<td style="text-align: center;">${goal.total_responded}</td>
<td style="text-align: center;">
<span style="font-weight: 600; color: ${goal.response_rate_pct > 30 ? 'var(--color-success)' : goal.response_rate_pct > 15 ? 'var(--color-warning)' : 'var(--color-text-secondary)'};">
${responseRate}
</span>
</td>
<td style="text-align: center;">
${goal.total_insights}
<span style="font-size: 10px; color: var(--color-text-muted);">${insightRate}</span>
</td>
<td>${sentimentBar}</td>
</tr>`;
}).join('');

table.style.display = 'table';
}
} catch (err) {
console.error('Failed to load analytics:', err);
document.getElementById('goal-stats-loading').textContent = 'Failed to load analytics';
}
}

// Initialize
init();
</script>
Expand Down
45 changes: 44 additions & 1 deletion server/public/admin-users.html
Original file line number Diff line number Diff line change
Expand Up @@ -2160,8 +2160,51 @@ <h3 style="margin: 0;">Strategic Insights</h3>
<span class="context-badge" style="background: var(--color-error-100); color: var(--color-error-700);">Opted Out</span>
</div>`;
}
html += '</div>';

html += '</div></div>';
// Detailed outreach history with conversation threads
if (context.outreach_history && context.outreach_history.length > 0) {
html += '<div style="margin-top: var(--space-3); max-height: 250px; overflow-y: auto;">';
context.outreach_history.forEach(outreach => {
const date = new Date(outreach.sent_at).toLocaleDateString();
const goalName = outreach.goal_name || 'General';
const hasResponse = outreach.user_responded;
const sentiment = outreach.response_sentiment;
const intent = outreach.response_intent;

// Sentiment badge colors
let sentimentBadge = '';
if (sentiment) {
const colors = {
positive: 'background: var(--color-success-100); color: var(--color-success-700);',
neutral: 'background: var(--color-gray-100); color: var(--color-gray-700);',
negative: 'background: var(--color-warning-100); color: var(--color-warning-700);',
refusal: 'background: var(--color-error-100); color: var(--color-error-700);'
};
sentimentBadge = `<span style="font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); ${colors[sentiment] || ''}">${sentiment}</span>`;
}

// Thread link if available
const threadLink = outreach.thread_id
? `<a href="/admin/addie?thread=${encodeURIComponent(outreach.thread_id)}" target="_blank" style="color: var(--color-brand); font-size: var(--text-xs);">View Thread (${outreach.thread_message_count || 0} msgs)</a>`
: '';

html += `<div style="background: var(--color-gray-50); border-radius: var(--radius-sm); padding: var(--space-2) var(--space-3); margin-bottom: var(--space-2);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-1);">
<span style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--color-text-heading);">${escapeHtml(goalName)}</span>
<span style="font-size: var(--text-xs); color: var(--color-text-tertiary);">${date}</span>
</div>
<div style="font-size: var(--text-xs); color: var(--color-text-muted); margin-bottom: var(--space-1);">
${hasResponse ? '✓ Responded' : '○ No response'} ${sentimentBadge}
</div>
${outreach.response_text ? `<div style="font-size: var(--text-xs); color: var(--color-text-secondary); font-style: italic; margin-bottom: var(--space-1);">"${escapeHtml(outreach.response_text.substring(0, 100))}${outreach.response_text.length > 100 ? '...' : ''}"</div>` : ''}
${threadLink}
</div>`;
});
html += '</div>';
}

html += '</div>';
}

// Impersonation Link (if WorkOS user exists)
Expand Down
137 changes: 137 additions & 0 deletions server/src/db/insights-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,43 @@ export interface OutreachStats {
insights_gathered: number;
}

export interface OutreachGoalStats {
goal_id: number;
goal_name: string;
goal_question: string;
goal_type: GoalType;
is_enabled: boolean;
total_sent: number;
total_responded: number;
total_insights: number;
response_rate_pct: number | null;
insight_conversion_rate_pct: number | null;
positive_responses: number;
neutral_responses: number;
negative_responses: number;
refusal_responses: number;
converted_count: number;
interested_count: number;
deferred_count: number;
question_count: number;
objection_count: number;
first_outreach_at: Date | null;
last_outreach_at: Date | null;
}

export interface OutreachTimeStats {
sent_today: number;
responded_today: number;
sent_this_week: number;
responded_this_week: number;
sent_this_month: number;
responded_this_month: number;
total_sent: number;
total_responded: number;
total_insights: number;
overall_response_rate_pct: number | null;
}

// =====================================================
// DATABASE CLASS
// =====================================================
Expand Down Expand Up @@ -1155,6 +1192,106 @@ export class InsightsDatabase {
};
}

/**
* Get outreach response rates broken down by goal
*/
async getOutreachGoalStats(): Promise<OutreachGoalStats[]> {
const result = await query<{
goal_id: string;
goal_name: string;
goal_question: string;
goal_type: GoalType;
is_enabled: boolean;
total_sent: string;
total_responded: string;
total_insights: string;
response_rate_pct: string | null;
insight_conversion_rate_pct: string | null;
positive_responses: string;
neutral_responses: string;
negative_responses: string;
refusal_responses: string;
converted_count: string;
interested_count: string;
deferred_count: string;
question_count: string;
objection_count: string;
first_outreach_at: Date | null;
last_outreach_at: Date | null;
}>('SELECT * FROM outreach_goal_stats ORDER BY total_sent DESC');

return result.rows.map(row => ({
goal_id: parseInt(row.goal_id, 10),
goal_name: row.goal_name,
goal_question: row.goal_question,
goal_type: row.goal_type,
is_enabled: row.is_enabled,
total_sent: parseInt(row.total_sent, 10),
total_responded: parseInt(row.total_responded, 10),
total_insights: parseInt(row.total_insights, 10),
response_rate_pct: row.response_rate_pct ? parseFloat(row.response_rate_pct) : null,
insight_conversion_rate_pct: row.insight_conversion_rate_pct ? parseFloat(row.insight_conversion_rate_pct) : null,
positive_responses: parseInt(row.positive_responses, 10),
neutral_responses: parseInt(row.neutral_responses, 10),
negative_responses: parseInt(row.negative_responses, 10),
refusal_responses: parseInt(row.refusal_responses, 10),
converted_count: parseInt(row.converted_count, 10),
interested_count: parseInt(row.interested_count, 10),
deferred_count: parseInt(row.deferred_count, 10),
question_count: parseInt(row.question_count, 10),
objection_count: parseInt(row.objection_count, 10),
first_outreach_at: row.first_outreach_at,
last_outreach_at: row.last_outreach_at,
}));
}

/**
* Get outreach time-windowed statistics
*/
async getOutreachTimeStats(): Promise<OutreachTimeStats> {
const result = await query<{
sent_today: string;
responded_today: string;
sent_this_week: string;
responded_this_week: string;
sent_this_month: string;
responded_this_month: string;
total_sent: string;
total_responded: string;
total_insights: string;
overall_response_rate_pct: string | null;
}>('SELECT * FROM outreach_time_stats');

const row = result.rows[0];
if (!row) {
return {
sent_today: 0,
responded_today: 0,
sent_this_week: 0,
responded_this_week: 0,
sent_this_month: 0,
responded_this_month: 0,
total_sent: 0,
total_responded: 0,
total_insights: 0,
overall_response_rate_pct: null,
};
}

return {
sent_today: parseInt(row.sent_today, 10),
responded_today: parseInt(row.responded_today, 10),
sent_this_week: parseInt(row.sent_this_week, 10),
responded_this_week: parseInt(row.responded_this_week, 10),
sent_this_month: parseInt(row.sent_this_month, 10),
responded_this_month: parseInt(row.responded_this_month, 10),
total_sent: parseInt(row.total_sent, 10),
total_responded: parseInt(row.total_responded, 10),
total_insights: parseInt(row.total_insights, 10),
overall_response_rate_pct: row.overall_response_rate_pct ? parseFloat(row.overall_response_rate_pct) : null,
};
}

/**
* Get recent outreach history for admin dashboard
*/
Expand Down
Loading