Skip to content

Commit 389e47c

Browse files
RajjjAryanCopilot
andcommitted
feat: add interactive web dashboard for job applications
- Express server at dashboard/web/server.mjs (port 3737) - Parses applications.md, pipeline.md, reports/ into JSON APIs - Dashboard UI with Catppuccin Mocha theme - Applications table: sortable, color-coded scores, clickable job URLs - Pipeline view: pending/evaluated entries with tier labels - Report viewer: renders evaluation reports in-browser - Sidebar: stats overview, score distribution, add-to-pipeline form - Quick commands: copy-to-clipboard for common AI agent commands - Status updates: click status badge to change application status - Auto-refresh every 15 seconds - npm run dashboard to start Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 775fd5d commit 389e47c

6 files changed

Lines changed: 2303 additions & 1 deletion

File tree

dashboard/web/public/app.js

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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

Comments
 (0)