PIOE / frontend /app.js
B1acB1rd
PIOE 2.0 ready for deploymnet
4d92cd5
/**
* PIOE - Personal Intelligence & Opportunity Engine
* Frontend JavaScript Application
*/
class PIOEApp {
constructor() {
this.currentCategory = null;
this.currentDomain = null;
this.minScore = 0;
this.opportunities = [];
this.init();
}
init() {
this.bindEvents();
this.loadStats();
this.loadOpportunities();
}
bindEvents() {
// Navigation items
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
this.setActiveNav(item);
this.handleViewChange(item.dataset.view);
});
});
// Category filters
document.querySelectorAll('.nav-item[data-category]').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
this.setActiveNav(item);
this.currentCategory = item.dataset.category;
this.loadOpportunities();
this.showFeedView();
});
});
// Domain filter
document.getElementById('domain-filter').addEventListener('change', (e) => {
this.currentDomain = e.target.value || null;
this.loadOpportunities();
});
// Score filter
document.getElementById('score-filter').addEventListener('change', (e) => {
this.minScore = parseFloat(e.target.value) || 0;
this.loadOpportunities();
});
// Run ingestion
document.getElementById('run-ingestion').addEventListener('click', (e) => {
e.preventDefault();
this.runIngestion();
});
// View stats
document.getElementById('view-stats').addEventListener('click', (e) => {
e.preventDefault();
this.showStatsModal();
});
// Modal close
document.querySelector('.modal-close').addEventListener('click', () => {
this.closeModal();
});
document.querySelector('.modal-backdrop').addEventListener('click', () => {
this.closeModal();
});
// PIOE 2.0: AI Chat
document.getElementById('open-chat')?.addEventListener('click', (e) => {
e.preventDefault();
this.toggleChat();
});
}
// PIOE 2.0: Chat Methods
toggleChat() {
const panel = document.getElementById('chat-panel');
panel.classList.toggle('active');
}
async sendChatMessage() {
const input = document.getElementById('chat-input');
const messagesContainer = document.getElementById('chat-messages');
const message = input.value.trim();
if (!message) return;
// Add user message to chat
messagesContainer.innerHTML += `
<div class="chat-message user">
<p>${this.escapeHtml(message)}</p>
</div>
`;
input.value = '';
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Add loading indicator
const loadingId = `loading-${Date.now()}`;
messagesContainer.innerHTML += `
<div class="chat-message bot" id="${loadingId}">
<p>[...] Searching opportunities...</p>
</div>
`;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
// Remove loading indicator
document.getElementById(loadingId)?.remove();
// Build response HTML
let responseHtml = `<p>${this.escapeHtml(data.response || 'No response')}</p>`;
// Add matched opportunities if any
if (data.opportunities && data.opportunities.length > 0) {
responseHtml += `<div style="margin-top: 12px">`;
for (const opp of data.opportunities) {
const roiDisplay = opp.roi_score ? `${Math.round(opp.roi_score * 100)}% ROI` : '';
responseHtml += `
<a href="${opp.url}" target="_blank" class="opp-link">
${this.getCategoryEmoji(opp.category)} ${this.escapeHtml(opp.title.slice(0, 60))}${opp.title.length > 60 ? '...' : ''}
<span style="opacity: 0.7; margin-left: 8px">${roiDisplay}</span>
</a>
`;
}
responseHtml += `</div>`;
}
// Add suggested action if any
if (data.suggested_action) {
responseHtml += `<p style="margin-top: 12px; font-style: italic; opacity: 0.8">[TIP] ${this.escapeHtml(data.suggested_action)}</p>`;
}
messagesContainer.innerHTML += `
<div class="chat-message bot">
${responseHtml}
</div>
`;
} catch (error) {
document.getElementById(loadingId)?.remove();
messagesContainer.innerHTML += `
<div class="chat-message bot">
<p style="color: var(--danger)">Error: ${error.message}</p>
</div>
`;
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
setActiveNav(activeItem) {
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
activeItem.classList.add('active');
}
handleViewChange(view) {
if (view === 'feed') {
this.currentCategory = null;
this.loadOpportunities();
this.showFeedView();
this.updateHeader('Opportunity Feed', 'High-signal opportunities detected by PIOE');
} else if (view === 'digest') {
this.loadDigest('daily');
this.showDigestView();
this.updateHeader('Daily Brief', 'Your personalized intelligence report');
} else if (view === 'urgent') {
this.loadDigest('urgent');
this.showDigestView();
this.updateHeader('Urgent Opportunities', 'Deadlines approaching soon');
}
}
updateHeader(title, subtitle) {
document.getElementById('page-title').textContent = title;
document.getElementById('page-subtitle').textContent = subtitle;
}
showFeedView() {
document.getElementById('opportunity-feed').style.display = 'flex';
document.getElementById('digest-view').style.display = 'none';
}
showDigestView() {
document.getElementById('opportunity-feed').style.display = 'none';
document.getElementById('digest-view').style.display = 'block';
}
async loadStats() {
try {
const response = await fetch('/api/stats');
const stats = await response.json();
document.getElementById('total-count').textContent = stats.total_opportunities || 0;
document.getElementById('new-count').textContent = stats.new_opportunities || 0;
document.getElementById('hackathon-count').textContent = stats.by_category?.hackathon || 0;
document.getElementById('internship-count').textContent = stats.by_category?.internship || 0;
} catch (error) {
console.error('Failed to load stats:', error);
}
}
async loadOpportunities() {
const feed = document.getElementById('opportunity-feed');
feed.innerHTML = '<div class="loading">Loading opportunities...</div>';
try {
const params = new URLSearchParams();
if (this.currentCategory) params.set('category', this.currentCategory);
if (this.currentDomain) params.set('domain', this.currentDomain);
if (this.minScore) params.set('min_score', this.minScore);
params.set('limit', '50');
const response = await fetch(`/api/opportunities?${params}`);
const data = await response.json();
this.opportunities = data.opportunities || [];
this.renderOpportunities();
} catch (error) {
feed.innerHTML = `<div class="loading">Error loading opportunities: ${error.message}</div>`;
}
}
renderOpportunities() {
const feed = document.getElementById('opportunity-feed');
if (this.opportunities.length === 0) {
feed.innerHTML = `
<div class="loading">
No opportunities found. Try running ingestion first!
</div>
`;
return;
}
feed.innerHTML = this.opportunities.map(opp => this.renderOpportunityCard(opp)).join('');
// Bind card click events
feed.querySelectorAll('.opportunity-card').forEach((card, index) => {
card.addEventListener('click', () => {
this.showOpportunityDetail(this.opportunities[index]);
});
// Action buttons
card.querySelector('.action-btn.primary')?.addEventListener('click', (e) => {
e.stopPropagation();
window.open(this.opportunities[index].url, '_blank');
});
card.querySelector('.action-btn.secondary')?.addEventListener('click', (e) => {
e.stopPropagation();
this.updateStatus(this.opportunities[index].id, 'saved');
});
});
}
renderOpportunityCard(opp) {
const category = opp.category || 'other';
const categoryEmoji = this.getCategoryEmoji(category);
const scorePercent = Math.round((opp.combined_score || 0) * 100);
const roiPercent = Math.round((opp.roi_score || 0.5) * 100);
const riskLevel = opp.risk_level || 'medium';
const region = opp.region || 'global';
let deadlineBadge = '';
if (opp.deadline) {
const daysLeft = Math.ceil((new Date(opp.deadline) - new Date()) / (1000 * 60 * 60 * 24));
let urgency = 'ok';
if (daysLeft < 7) urgency = 'urgent';
else if (daysLeft < 14) urgency = 'soon';
deadlineBadge = `
<span class="deadline-badge ${urgency}">
[!] ${daysLeft} days left
</span>
`;
}
// Risk level badge
const riskColors = { low: '#10b981', medium: '#f59e0b', high: '#ef4444' };
const riskLabels = { low: '[OK]', medium: '[!]', high: '[!!]' };
// Region badge
const regionLabels = { nigeria: 'NG', africa: 'AFR', global: 'GLB', remote_africa: 'AFR-R', remote_global: 'GLB-R' };
return `
<div class="opportunity-card">
<div class="card-header">
<span class="card-category ${category}">
${categoryEmoji} ${category.replace('_', ' ')}
</span>
<div class="card-score">
<div class="score-bar">
<div class="score-fill" style="width: ${scorePercent}%"></div>
</div>
<span>${scorePercent}%</span>
</div>
</div>
<h3 class="card-title">${this.escapeHtml(opp.title)}</h3>
<div class="card-meta">
<span>[SRC] ${opp.source_name || 'Unknown'}</span>
<span>[${regionLabels[region] || 'GLB'}] ${region.replace('_', ' ')}</span>
<span style="color: ${riskColors[riskLevel]}">${riskLabels[riskLevel]} ${riskLevel} risk</span>
</div>
<div class="card-meta" style="margin-top: 8px">
<span title="ROI Score">[ROI] ${roiPercent}%</span>
<span>[DATE] ${this.formatDate(opp.discovered_at)}</span>
</div>
<p class="card-summary">${this.escapeHtml(opp.raw_text?.slice(0, 200) || '')}</p>
<div class="card-footer">
${deadlineBadge}
<div class="card-actions">
<button class="action-btn secondary">Save</button>
<button class="action-btn primary">Open</button>
</div>
</div>
</div>
`;
}
getCategoryEmoji(category) {
const labels = {
scholarship: '[S]',
fellowship: '[F]',
internship: '[I]',
job: '[J]',
hackathon: '[H]',
competition: '[C]',
grant: '[G]',
micro_grant: '[MG]',
ecosystem_grant: '[EG]',
innovation_fund: '[IF]',
research: '[R]',
open_source: '[OS]',
conference: '[CF]',
investment: '[IV]',
partnership: '[P]',
collaboration: '[CO]',
pitch_event: '[PE]',
demo_day: '[DD]',
talent_call: '[TC]',
bounty: '[B]',
ambassador: '[A]',
pre_grant_signal: '[PG]',
pre_hiring_signal: '[PH]',
weak_signal: '[WS]',
other: '[?]'
};
return labels[category] || '[?]';
}
async loadDigest(type) {
const content = document.getElementById('digest-content');
content.innerHTML = '<div class="loading">Generating digest...</div>';
try {
const response = await fetch(`/api/digest/${type}`);
const data = await response.json();
// Convert markdown to HTML (simple conversion)
content.innerHTML = this.markdownToHtml(data.digest || 'No digest available.');
} catch (error) {
content.innerHTML = `<p>Error loading digest: ${error.message}</p>`;
}
}
markdownToHtml(md) {
return md
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>')
.replace(/^---$/gim, '<hr>')
.replace(/\n/g, '<br>');
}
showOpportunityDetail(opp) {
const modal = document.getElementById('detail-modal');
const body = document.getElementById('modal-body');
const roiPercent = Math.round((opp.roi_score || 0.5) * 100);
const riskLevel = opp.risk_level || 'medium';
const region = opp.region || 'global';
const riskColors = { low: '#10b981', medium: '#f59e0b', high: '#ef4444' };
body.innerHTML = `
<span class="card-category ${opp.category}" style="margin-bottom: 16px">
${this.getCategoryEmoji(opp.category)} ${(opp.category || 'other').replace('_', ' ')}
</span>
<h2 style="margin: 16px 0">${this.escapeHtml(opp.title)}</h2>
<div class="card-meta" style="margin-bottom: 20px">
<span>📡 ${opp.source_name}</span>
<span>🌐 ${region.replace('_', ' ')}</span>
<span style="color: ${riskColors[riskLevel]}">${riskLevel} risk</span>
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px">
<div class="stat-card">
<span class="stat-value">${Math.round((opp.relevance_score || 0) * 100)}%</span>
<span class="stat-label">Relevance</span>
</div>
<div class="stat-card">
<span class="stat-value">${Math.round((opp.novelty_score || 0) * 100)}%</span>
<span class="stat-label">Novelty</span>
</div>
<div class="stat-card">
<span class="stat-value">${Math.round((opp.credibility_score || 0) * 100)}%</span>
<span class="stat-label">Credibility</span>
</div>
<div class="stat-card highlight">
<span class="stat-value">${roiPercent}%</span>
<span class="stat-label">💎 ROI</span>
</div>
</div>
${opp.deadline ? `<p style="color: var(--warning); margin-bottom: 16px">⏰ Deadline: ${new Date(opp.deadline).toLocaleDateString()}</p>` : ''}
<p style="color: var(--text-secondary); line-height: 1.8; margin-bottom: 24px">
${this.escapeHtml(opp.raw_text || 'No description available.')}
</p>
<!-- Action Guidance Container -->
<div id="guidance-container" style="margin-bottom: 24px; padding: 16px; background: rgba(99, 102, 241, 0.1); border-radius: 12px; display: none;">
<h3 style="margin-bottom: 12px; color: var(--accent)">🎯 Action Guidance</h3>
<div id="guidance-content"></div>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px">
<button class="action-btn primary" onclick="app.getGuidance('${opp.id}')" style="padding: 12px 24px; background: linear-gradient(135deg, #8b5cf6, #6366f1)">
🧠 Get Guidance
</button>
<a href="${opp.url}" target="_blank" class="action-btn primary" style="text-decoration: none; padding: 12px 24px">
🔗 View Original
</a>
<button class="action-btn secondary" onclick="app.updateStatus('${opp.id}', 'saved')" style="padding: 12px 24px">
💾 Save
</button>
<button class="action-btn secondary" onclick="app.updateStatus('${opp.id}', 'applied')" style="padding: 12px 24px">
✅ Mark Applied
</button>
</div>
`;
modal.classList.add('active');
}
async getGuidance(opportunityId) {
const container = document.getElementById('guidance-container');
const content = document.getElementById('guidance-content');
container.style.display = 'block';
content.innerHTML = '<p>🔄 Analyzing opportunity...</p>';
try {
const response = await fetch(`/api/opportunities/${opportunityId}/guidance`);
const data = await response.json();
const g = data.guidance;
content.innerHTML = `
<div style="display: grid; gap: 16px">
<div style="display: flex; gap: 16px; flex-wrap: wrap">
<div class="stat-card" style="flex: 1; min-width: 120px">
<span class="stat-value" style="font-size: 14px">${g.primary_action?.replace('_', ' ') || 'Review'}</span>
<span class="stat-label">Action</span>
</div>
<div class="stat-card" style="flex: 1; min-width: 120px">
<span class="stat-value" style="font-size: 14px">${g.urgency || 'whenever'}</span>
<span class="stat-label">Urgency</span>
</div>
<div class="stat-card" style="flex: 1; min-width: 120px">
<span class="stat-value" style="font-size: 14px">${Math.round((g.success_probability || 0.3) * 100)}%</span>
<span class="stat-label">Success Odds</span>
</div>
<div class="stat-card" style="flex: 1; min-width: 120px">
<span class="stat-value" style="font-size: 14px">${g.time_investment_hours || 10}h</span>
<span class="stat-label">Time Needed</span>
</div>
</div>
${g.skills_to_highlight?.length ? `
<div>
<strong>Skills to Highlight:</strong>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px">
${g.skills_to_highlight.map(s => `<span style="background: var(--accent); padding: 4px 12px; border-radius: 20px; font-size: 12px">${s}</span>`).join('')}
</div>
</div>
` : ''}
${g.portfolio_pieces?.length ? `
<div>
<strong>Portfolio to Show:</strong>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px">
${g.portfolio_pieces.map(p => `<span style="background: var(--success); padding: 4px 12px; border-radius: 20px; font-size: 12px">${p}</span>`).join('')}
</div>
</div>
` : ''}
${g.preparation_steps?.length ? `
<div>
<strong>Preparation Steps:</strong>
<ol style="margin-top: 8px; padding-left: 20px">
${g.preparation_steps.map(s => `<li style="margin-bottom: 4px">${s}</li>`).join('')}
</ol>
</div>
` : ''}
${g.networking_tips ? `
<div>
<strong>💡 Networking Tip:</strong>
<p style="margin-top: 4px; color: var(--text-secondary)">${g.networking_tips}</p>
</div>
` : ''}
${g.differentiation_angle ? `
<div>
<strong>🎯 Your Angle:</strong>
<p style="margin-top: 4px; color: var(--text-secondary)">${g.differentiation_angle}</p>
</div>
` : ''}
${g.red_flags?.length ? `
<div style="background: rgba(239, 68, 68, 0.1); padding: 12px; border-radius: 8px">
<strong style="color: #ef4444">⚠️ Red Flags:</strong>
<ul style="margin-top: 8px; padding-left: 20px">
${g.red_flags.map(f => `<li style="color: #ef4444">${f}</li>`).join('')}
</ul>
</div>
` : ''}
<p style="font-style: italic; color: var(--text-secondary); font-size: 12px">
${g.why || 'Personalized guidance based on your profile'}
</p>
</div>
`;
} catch (error) {
content.innerHTML = `<p style="color: var(--error)">Failed to get guidance: ${error.message}</p>`;
}
}
closeModal() {
document.getElementById('detail-modal').classList.remove('active');
}
async updateStatus(id, status) {
try {
await fetch(`/api/opportunities/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
// Visual feedback
this.showNotification(`Status updated to ${status}`);
} catch (error) {
console.error('Failed to update status:', error);
}
}
async runIngestion() {
this.showNotification('Starting ingestion... This may take a few minutes.');
try {
await fetch('/api/ingest/run', { method: 'POST' });
this.showNotification('Ingestion started! Refresh in a few minutes to see new opportunities.');
} catch (error) {
this.showNotification('Failed to start ingestion: ' + error.message);
}
}
async showStatsModal() {
try {
const response = await fetch('/api/stats');
const stats = await response.json();
const body = document.getElementById('modal-body');
body.innerHTML = `
<h2 style="margin-bottom: 24px">📊 System Statistics</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px">
<div class="stat-card highlight">
<span class="stat-value">${stats.total_opportunities || 0}</span>
<span class="stat-label">Total Opportunities</span>
</div>
<div class="stat-card">
<span class="stat-value">${stats.new_opportunities || 0}</span>
<span class="stat-label">New (Unread)</span>
</div>
</div>
<h3 style="margin: 24px 0 16px">By Category</h3>
${Object.entries(stats.by_category || {}).map(([cat, count]) => `
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border-color)">
<span>${this.getCategoryEmoji(cat)} ${cat.replace('_', ' ')}</span>
<span style="font-weight: 600">${count}</span>
</div>
`).join('')}
<h3 style="margin: 24px 0 16px">By Domain</h3>
${Object.entries(stats.by_domain || {}).map(([dom, count]) => `
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border-color)">
<span>${dom.replace('_', ' ')}</span>
<span style="font-weight: 600">${count}</span>
</div>
`).join('')}
`;
document.getElementById('detail-modal').classList.add('active');
} catch (error) {
console.error('Failed to load stats:', error);
}
}
showNotification(message) {
// Simple notification - could be enhanced with toast UI
console.log('PIOE:', message);
alert(message);
}
formatDate(dateStr) {
if (!dateStr) return 'Unknown';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize app
const app = new PIOEApp();