|
| 1 | +// Career-Copilot Dashboard — Client-Side Application |
| 2 | + |
| 3 | +const API = ''; |
| 4 | +let currentSort = { field: 'num', dir: 'asc' }; |
| 5 | +let appData = []; |
| 6 | +let pipelineData = { evaluated: [], pending: [] }; |
| 7 | +let statsData = {}; |
| 8 | + |
| 9 | +// ─── Init ──────────────────────────────────────────────────────────────────── |
| 10 | + |
| 11 | +document.addEventListener('DOMContentLoaded', () => { |
| 12 | + setupTabs(); |
| 13 | + setupSortHeaders(); |
| 14 | + setupAddUrlForm(); |
| 15 | + setupCommands(); |
| 16 | + loadAll(); |
| 17 | + // Auto-refresh every 15 seconds |
| 18 | + setInterval(loadAll, 15000); |
| 19 | +}); |
| 20 | + |
| 21 | +async function loadAll() { |
| 22 | + await Promise.all([loadApplications(), loadPipeline(), loadStats(), loadReports()]); |
| 23 | +} |
| 24 | + |
| 25 | +// ─── Data Loading ──────────────────────────────────────────────────────────── |
| 26 | + |
| 27 | +async function loadApplications() { |
| 28 | + try { |
| 29 | + const res = await fetch(`${API}/api/applications`); |
| 30 | + appData = await res.json(); |
| 31 | + renderApplications(); |
| 32 | + } catch (e) { console.error('Failed to load applications:', e); } |
| 33 | +} |
| 34 | + |
| 35 | +async function loadPipeline() { |
| 36 | + try { |
| 37 | + const res = await fetch(`${API}/api/pipeline`); |
| 38 | + pipelineData = await res.json(); |
| 39 | + renderPipeline(); |
| 40 | + document.getElementById('pipeCount').textContent = pipelineData.pending.length; |
| 41 | + } catch (e) { console.error('Failed to load pipeline:', e); } |
| 42 | +} |
| 43 | + |
| 44 | +async function loadStats() { |
| 45 | + try { |
| 46 | + const res = await fetch(`${API}/api/stats`); |
| 47 | + statsData = await res.json(); |
| 48 | + renderStats(); |
| 49 | + renderHeaderStats(); |
| 50 | + } catch (e) { console.error('Failed to load stats:', e); } |
| 51 | +} |
| 52 | + |
| 53 | +async function loadReports() { |
| 54 | + try { |
| 55 | + const res = await fetch(`${API}/api/reports`); |
| 56 | + const reports = await res.json(); |
| 57 | + document.getElementById('reportCount').textContent = reports.length; |
| 58 | + renderReportsList(reports); |
| 59 | + } catch (e) { console.error('Failed to load reports:', e); } |
| 60 | +} |
| 61 | + |
| 62 | +// ─── Applications Rendering ────────────────────────────────────────────────── |
| 63 | + |
| 64 | +function renderApplications() { |
| 65 | + const sorted = [...appData].sort((a, b) => { |
| 66 | + let va = a[currentSort.field], vb = b[currentSort.field]; |
| 67 | + if (currentSort.field === 'score') { va = a.score; vb = b.score; } |
| 68 | + if (currentSort.field === 'num') { va = parseInt(a.num); vb = parseInt(b.num); } |
| 69 | + if (typeof va === 'string') { va = va.toLowerCase(); vb = vb.toLowerCase(); } |
| 70 | + if (va < vb) return currentSort.dir === 'asc' ? -1 : 1; |
| 71 | + if (va > vb) return currentSort.dir === 'asc' ? 1 : -1; |
| 72 | + return 0; |
| 73 | + }); |
| 74 | + |
| 75 | + document.getElementById('appCount').textContent = appData.length; |
| 76 | + const tbody = document.getElementById('appTableBody'); |
| 77 | + const empty = document.getElementById('appEmpty'); |
| 78 | + |
| 79 | + if (sorted.length === 0) { |
| 80 | + tbody.innerHTML = ''; |
| 81 | + empty.style.display = ''; |
| 82 | + return; |
| 83 | + } |
| 84 | + empty.style.display = 'none'; |
| 85 | + |
| 86 | + tbody.innerHTML = sorted.map(app => ` |
| 87 | + <tr> |
| 88 | + <td class="date-cell">${app.num}</td> |
| 89 | + <td class="date-cell">${app.date}</td> |
| 90 | + <td class="company-cell">${app.jobUrl |
| 91 | + ? `<a href="${escHtml(app.jobUrl)}" target="_blank" rel="noopener">${escHtml(app.company)}</a>` |
| 92 | + : escHtml(app.company) |
| 93 | + }</td> |
| 94 | + <td class="role-cell" title="${escHtml(app.role)}">${escHtml(app.role)}</td> |
| 95 | + <td><span class="score-badge ${scoreClass(app.score)}">${app.score.toFixed(1)}</span></td> |
| 96 | + <td> |
| 97 | + <div class="status-dropdown"> |
| 98 | + <span class="status-badge status-${app.status.toLowerCase()}" onclick="toggleStatus(this, '${app.num}')">${escHtml(app.status)}</span> |
| 99 | + <div class="status-options" id="status-${app.num}"> |
| 100 | + ${['Evaluated','Applied','Responded','Interview','Offer','Rejected','Discarded','SKIP'] |
| 101 | + .map(s => `<div class="status-option" onclick="updateStatus('${app.num}','${s}')">${s}</div>`) |
| 102 | + .join('')} |
| 103 | + </div> |
| 104 | + </div> |
| 105 | + </td> |
| 106 | + <td>${app.hasPdf ? '📄' : '—'}</td> |
| 107 | + <td>${app.reportPath |
| 108 | + ? `<span class="report-link" onclick="viewReport('${escHtml(app.reportPath.split('/').pop())}')">#${app.reportNum}</span>` |
| 109 | + : '—' |
| 110 | + }</td> |
| 111 | + <td class="notes-cell" title="${escHtml(app.notes)}">${escHtml(app.notes)}</td> |
| 112 | + </tr> |
| 113 | + `).join(''); |
| 114 | +} |
| 115 | + |
| 116 | +function scoreClass(score) { |
| 117 | + if (score >= 4.5) return 'score-strong'; |
| 118 | + if (score >= 4.0) return 'score-good'; |
| 119 | + if (score >= 3.5) return 'score-decent'; |
| 120 | + return 'score-weak'; |
| 121 | +} |
| 122 | + |
| 123 | +// ─── Status Update ─────────────────────────────────────────────────────────── |
| 124 | + |
| 125 | +function toggleStatus(el, num) { |
| 126 | + // Close all other dropdowns |
| 127 | + document.querySelectorAll('.status-options.open').forEach(d => d.classList.remove('open')); |
| 128 | + const dropdown = document.getElementById(`status-${num}`); |
| 129 | + dropdown.classList.toggle('open'); |
| 130 | +} |
| 131 | + |
| 132 | +async function updateStatus(num, status) { |
| 133 | + document.querySelectorAll('.status-options.open').forEach(d => d.classList.remove('open')); |
| 134 | + try { |
| 135 | + const res = await fetch(`${API}/api/applications/${num}`, { |
| 136 | + method: 'PATCH', |
| 137 | + headers: { 'Content-Type': 'application/json' }, |
| 138 | + body: JSON.stringify({ status }) |
| 139 | + }); |
| 140 | + if (res.ok) { |
| 141 | + showToast(`Updated #${num} → ${status}`); |
| 142 | + await loadApplications(); |
| 143 | + } else { |
| 144 | + showToast('Failed to update status', true); |
| 145 | + } |
| 146 | + } catch (e) { |
| 147 | + showToast('Error updating status', true); |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +// Close dropdowns on outside click |
| 152 | +document.addEventListener('click', (e) => { |
| 153 | + if (!e.target.closest('.status-dropdown')) { |
| 154 | + document.querySelectorAll('.status-options.open').forEach(d => d.classList.remove('open')); |
| 155 | + } |
| 156 | +}); |
| 157 | + |
| 158 | +// ─── Pipeline Rendering ───────────────────────────────────────────────────── |
| 159 | + |
| 160 | +function renderPipeline() { |
| 161 | + const pendingEl = document.getElementById('pipelinePending'); |
| 162 | + const evalEl = document.getElementById('pipelineEvaluated'); |
| 163 | + document.getElementById('pendingCount').textContent = pipelineData.pending.length; |
| 164 | + document.getElementById('evalCount').textContent = pipelineData.evaluated.length; |
| 165 | + |
| 166 | + pendingEl.innerHTML = pipelineData.pending.length === 0 |
| 167 | + ? '<div class="empty-state"><p>No pending evaluations</p></div>' |
| 168 | + : pipelineData.pending.map(p => ` |
| 169 | + <div class="pipeline-item"> |
| 170 | + <div> |
| 171 | + <span class="pipeline-company">${escHtml(p.company || 'Unknown')}</span> |
| 172 | + <span class="pipeline-role"> — ${escHtml(p.role || 'Unknown Role')}</span> |
| 173 | + </div> |
| 174 | + <span class="pipeline-location">${escHtml(p.location || '')}</span> |
| 175 | + <span class="tier-label">${p.tier.includes('1') ? 'Tier 1' : 'Tier 2'}</span> |
| 176 | + <span class="pipeline-url"><a href="${escHtml(p.url)}" target="_blank" rel="noopener">Open ↗</a></span> |
| 177 | + </div> |
| 178 | + `).join(''); |
| 179 | + |
| 180 | + evalEl.innerHTML = pipelineData.evaluated.length === 0 |
| 181 | + ? '<div class="empty-state"><p>No evaluated entries</p></div>' |
| 182 | + : pipelineData.evaluated.map(p => ` |
| 183 | + <div class="pipeline-item"> |
| 184 | + <div> |
| 185 | + <span class="pipeline-company">${escHtml(p.company)}</span> |
| 186 | + <span class="pipeline-role"> — ${escHtml(p.role)}</span> |
| 187 | + </div> |
| 188 | + <span class="score-badge ${scoreClass(p.score)}">${p.grade} (${p.score.toFixed(1)})</span> |
| 189 | + <span class="tier-label">${p.tier.includes('1') ? 'Tier 1' : 'Tier 2'}</span> |
| 190 | + <span class="notes-cell">${escHtml(p.notes)}</span> |
| 191 | + </div> |
| 192 | + `).join(''); |
| 193 | +} |
| 194 | + |
| 195 | +// ─── Stats Rendering ──────────────────────────────────────────────────────── |
| 196 | + |
| 197 | +function renderStats() { |
| 198 | + const grid = document.getElementById('statsGrid'); |
| 199 | + grid.innerHTML = ` |
| 200 | + <div class="stat-card highlight"> |
| 201 | + <div class="stat-number">${statsData.total || 0}</div> |
| 202 | + <div class="stat-label">Total</div> |
| 203 | + </div> |
| 204 | + <div class="stat-card"> |
| 205 | + <div class="stat-number">${statsData.avgScore || 0}</div> |
| 206 | + <div class="stat-label">Avg Score</div> |
| 207 | + </div> |
| 208 | + <div class="stat-card"> |
| 209 | + <div class="stat-number">${statsData.topScore || 0}</div> |
| 210 | + <div class="stat-label">Top Score</div> |
| 211 | + </div> |
| 212 | + <div class="stat-card"> |
| 213 | + <div class="stat-number">${statsData.pipelinePending || 0}</div> |
| 214 | + <div class="stat-label">Pipeline</div> |
| 215 | + </div> |
| 216 | + `; |
| 217 | + |
| 218 | + const dist = document.getElementById('distChart'); |
| 219 | + const d = statsData.distribution || { strong: 0, good: 0, decent: 0, weak: 0 }; |
| 220 | + const max = Math.max(d.strong, d.good, d.decent, d.weak, 1); |
| 221 | + dist.innerHTML = ` |
| 222 | + <div class="dist-row"> |
| 223 | + <span class="dist-label">≥ 4.5</span> |
| 224 | + <div class="dist-bar"><div class="dist-fill strong" style="width:${(d.strong/max)*100}%"></div></div> |
| 225 | + <span class="dist-count">${d.strong}</span> |
| 226 | + </div> |
| 227 | + <div class="dist-row"> |
| 228 | + <span class="dist-label">4.0–4.4</span> |
| 229 | + <div class="dist-bar"><div class="dist-fill good" style="width:${(d.good/max)*100}%"></div></div> |
| 230 | + <span class="dist-count">${d.good}</span> |
| 231 | + </div> |
| 232 | + <div class="dist-row"> |
| 233 | + <span class="dist-label">3.5–3.9</span> |
| 234 | + <div class="dist-bar"><div class="dist-fill decent" style="width:${(d.decent/max)*100}%"></div></div> |
| 235 | + <span class="dist-count">${d.decent}</span> |
| 236 | + </div> |
| 237 | + <div class="dist-row"> |
| 238 | + <span class="dist-label">< 3.5</span> |
| 239 | + <div class="dist-bar"><div class="dist-fill weak" style="width:${(d.weak/max)*100}%"></div></div> |
| 240 | + <span class="dist-count">${d.weak}</span> |
| 241 | + </div> |
| 242 | + `; |
| 243 | +} |
| 244 | + |
| 245 | +function renderHeaderStats() { |
| 246 | + const el = document.getElementById('headerStats'); |
| 247 | + const byStatus = statsData.byStatus || {}; |
| 248 | + el.innerHTML = ` |
| 249 | + <span>Total:<span class="stat-value">${statsData.total || 0}</span></span> |
| 250 | + <span>Avg:<span class="stat-value">${statsData.avgScore || 0}</span></span> |
| 251 | + <span>Pipeline:<span class="stat-value">${statsData.pipelinePending || 0}</span></span> |
| 252 | + ${byStatus.Interview ? `<span>Interviews:<span class="stat-value">${byStatus.Interview}</span></span>` : ''} |
| 253 | + ${byStatus.Offer ? `<span>Offers:<span class="stat-value">${byStatus.Offer}</span></span>` : ''} |
| 254 | + `; |
| 255 | +} |
| 256 | + |
| 257 | +// ─── Reports ───────────────────────────────────────────────────────────────── |
| 258 | + |
| 259 | +function renderReportsList(reports) { |
| 260 | + const el = document.getElementById('reportsList'); |
| 261 | + if (reports.length === 0) { |
| 262 | + el.innerHTML = '<div class="empty-state"><div class="empty-icon">📝</div><p>No reports yet</p></div>'; |
| 263 | + return; |
| 264 | + } |
| 265 | + el.innerHTML = reports.map(r => ` |
| 266 | + <div class="pipeline-item" style="cursor:pointer" onclick="viewReport('${escHtml(r.filename)}')"> |
| 267 | + <div> |
| 268 | + <span class="pipeline-company">#${r.num} — ${escHtml(r.slug)}</span> |
| 269 | + </div> |
| 270 | + <span class="date-cell">${r.date}</span> |
| 271 | + <span></span> |
| 272 | + <span class="report-link">View →</span> |
| 273 | + </div> |
| 274 | + `).join(''); |
| 275 | +} |
| 276 | + |
| 277 | +async function viewReport(filename) { |
| 278 | + const listEl = document.getElementById('reportsList'); |
| 279 | + const viewerEl = document.getElementById('reportViewer'); |
| 280 | + try { |
| 281 | + const res = await fetch(`${API}/api/reports/${encodeURIComponent(filename)}`); |
| 282 | + const report = await res.json(); |
| 283 | + listEl.style.display = 'none'; |
| 284 | + viewerEl.style.display = ''; |
| 285 | + viewerEl.innerHTML = ` |
| 286 | + <div class="report-viewer"> |
| 287 | + <div class="report-back" onclick="closeReport()">← Back to reports</div> |
| 288 | + ${report.html} |
| 289 | + </div> |
| 290 | + `; |
| 291 | + } catch (e) { |
| 292 | + showToast('Failed to load report', true); |
| 293 | + } |
| 294 | +} |
| 295 | + |
| 296 | +function closeReport() { |
| 297 | + document.getElementById('reportsList').style.display = ''; |
| 298 | + document.getElementById('reportViewer').style.display = 'none'; |
| 299 | +} |
| 300 | + |
| 301 | +// ─── Tabs ──────────────────────────────────────────────────────────────────── |
| 302 | + |
| 303 | +function setupTabs() { |
| 304 | + document.querySelectorAll('.tab').forEach(tab => { |
| 305 | + tab.addEventListener('click', () => { |
| 306 | + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); |
| 307 | + document.querySelectorAll('.tab-panel').forEach(p => p.style.display = 'none'); |
| 308 | + tab.classList.add('active'); |
| 309 | + document.getElementById(`panel-${tab.dataset.tab}`).style.display = ''; |
| 310 | + // Reset report viewer when switching away |
| 311 | + if (tab.dataset.tab !== 'reports') closeReport(); |
| 312 | + }); |
| 313 | + }); |
| 314 | +} |
| 315 | + |
| 316 | +// ─── Sort Headers ──────────────────────────────────────────────────────────── |
| 317 | + |
| 318 | +function setupSortHeaders() { |
| 319 | + document.querySelectorAll('.app-table th[data-sort]').forEach(th => { |
| 320 | + th.addEventListener('click', () => { |
| 321 | + const field = th.dataset.sort; |
| 322 | + if (currentSort.field === field) { |
| 323 | + currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc'; |
| 324 | + } else { |
| 325 | + currentSort = { field, dir: 'asc' }; |
| 326 | + } |
| 327 | + // Update sort indicators |
| 328 | + document.querySelectorAll('.app-table th').forEach(h => h.classList.remove('sorted')); |
| 329 | + th.classList.add('sorted'); |
| 330 | + th.querySelector('.sort-arrow').textContent = currentSort.dir === 'asc' ? '↑' : '↓'; |
| 331 | + renderApplications(); |
| 332 | + }); |
| 333 | + }); |
| 334 | +} |
| 335 | + |
| 336 | +// ─── Add URL Form ──────────────────────────────────────────────────────────── |
| 337 | + |
| 338 | +function setupAddUrlForm() { |
| 339 | + document.getElementById('addUrlForm').addEventListener('submit', async (e) => { |
| 340 | + e.preventDefault(); |
| 341 | + const form = e.target; |
| 342 | + const data = { |
| 343 | + url: form.url.value, |
| 344 | + company: form.company.value, |
| 345 | + role: form.role.value, |
| 346 | + location: form.location.value |
| 347 | + }; |
| 348 | + try { |
| 349 | + const res = await fetch(`${API}/api/pipeline`, { |
| 350 | + method: 'POST', |
| 351 | + headers: { 'Content-Type': 'application/json' }, |
| 352 | + body: JSON.stringify(data) |
| 353 | + }); |
| 354 | + if (res.ok) { |
| 355 | + showToast('Added to pipeline! Tell your AI agent: "process pipeline"'); |
| 356 | + form.reset(); |
| 357 | + await loadPipeline(); |
| 358 | + } else { |
| 359 | + const err = await res.json(); |
| 360 | + showToast(err.error || 'Failed to add', true); |
| 361 | + } |
| 362 | + } catch (e) { |
| 363 | + showToast('Error adding to pipeline', true); |
| 364 | + } |
| 365 | + }); |
| 366 | +} |
| 367 | + |
| 368 | +// ─── Commands (copy to clipboard) ──────────────────────────────────────────── |
| 369 | + |
| 370 | +function setupCommands() { |
| 371 | + document.querySelectorAll('.command-item').forEach(item => { |
| 372 | + item.addEventListener('click', async () => { |
| 373 | + const cmd = item.dataset.cmd; |
| 374 | + try { |
| 375 | + await navigator.clipboard.writeText(cmd); |
| 376 | + showToast(`Copied: "${cmd}"`); |
| 377 | + item.querySelector('.command-copy').textContent = '✅ copied'; |
| 378 | + setTimeout(() => { item.querySelector('.command-copy').textContent = '📋 copy'; }, 2000); |
| 379 | + } catch { |
| 380 | + showToast('Copy failed — select and copy manually', true); |
| 381 | + } |
| 382 | + }); |
| 383 | + }); |
| 384 | +} |
| 385 | + |
| 386 | +// ─── Utilities ─────────────────────────────────────────────────────────────── |
| 387 | + |
| 388 | +function escHtml(str) { |
| 389 | + if (!str) return ''; |
| 390 | + return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| 391 | +} |
| 392 | + |
| 393 | +function showToast(msg, isError = false) { |
| 394 | + const toast = document.getElementById('toast'); |
| 395 | + toast.textContent = msg; |
| 396 | + toast.className = 'toast show' + (isError ? ' error' : ''); |
| 397 | + setTimeout(() => { toast.className = 'toast'; }, 3000); |
| 398 | +} |
0 commit comments