| |
| |
| |
| |
|
|
| class PIOEApp { |
| constructor() { |
| this.currentCategory = null; |
| this.currentDomain = null; |
| this.minScore = 0; |
| this.opportunities = []; |
|
|
| this.init(); |
| } |
|
|
| init() { |
| this.bindEvents(); |
| this.loadStats(); |
| this.loadOpportunities(); |
| } |
|
|
| bindEvents() { |
| |
| document.querySelectorAll('.nav-item[data-view]').forEach(item => { |
| item.addEventListener('click', (e) => { |
| e.preventDefault(); |
| this.setActiveNav(item); |
| this.handleViewChange(item.dataset.view); |
| }); |
| }); |
|
|
| |
| 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(); |
| }); |
| }); |
|
|
| |
| document.getElementById('domain-filter').addEventListener('change', (e) => { |
| this.currentDomain = e.target.value || null; |
| this.loadOpportunities(); |
| }); |
|
|
| |
| document.getElementById('score-filter').addEventListener('change', (e) => { |
| this.minScore = parseFloat(e.target.value) || 0; |
| this.loadOpportunities(); |
| }); |
|
|
| |
| document.getElementById('run-ingestion').addEventListener('click', (e) => { |
| e.preventDefault(); |
| this.runIngestion(); |
| }); |
|
|
| |
| document.getElementById('view-stats').addEventListener('click', (e) => { |
| e.preventDefault(); |
| this.showStatsModal(); |
| }); |
|
|
| |
| document.querySelector('.modal-close').addEventListener('click', () => { |
| this.closeModal(); |
| }); |
|
|
| document.querySelector('.modal-backdrop').addEventListener('click', () => { |
| this.closeModal(); |
| }); |
|
|
| |
| document.getElementById('open-chat')?.addEventListener('click', (e) => { |
| e.preventDefault(); |
| this.toggleChat(); |
| }); |
| } |
|
|
| |
| 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; |
|
|
| |
| messagesContainer.innerHTML += ` |
| <div class="chat-message user"> |
| <p>${this.escapeHtml(message)}</p> |
| </div> |
| `; |
| input.value = ''; |
| messagesContainer.scrollTop = messagesContainer.scrollHeight; |
|
|
| |
| 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(); |
|
|
| |
| document.getElementById(loadingId)?.remove(); |
|
|
| |
| let responseHtml = `<p>${this.escapeHtml(data.response || 'No response')}</p>`; |
|
|
| |
| 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>`; |
| } |
|
|
| |
| 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(''); |
|
|
| |
| feed.querySelectorAll('.opportunity-card').forEach((card, index) => { |
| card.addEventListener('click', () => { |
| this.showOpportunityDetail(this.opportunities[index]); |
| }); |
|
|
| |
| 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> |
| `; |
| } |
|
|
| |
| const riskColors = { low: '#10b981', medium: '#f59e0b', high: '#ef4444' }; |
| const riskLabels = { low: '[OK]', medium: '[!]', high: '[!!]' }; |
|
|
| |
| 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(); |
|
|
| |
| 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 }) |
| }); |
|
|
| |
| 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) { |
| |
| 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; |
| } |
| } |
|
|
| |
| const app = new PIOEApp(); |
|
|