Phase 2: Market Intel + Smart Match + Ops Timeline

This commit is contained in:
Tony0410
2026-07-20 21:20:17 +08:00
parent c6476462b7
commit 5bb4b8705c
3 changed files with 433 additions and 10 deletions

View File

@@ -6,12 +6,12 @@
<title>Job Tracker · Anthony</title>
<script>
const API_BASE = (window.location.pathname.replace(/\/index\.html$/,'').replace(/\/+$/,'')) || '';
const API = { jobs:API_BASE+'/api/jobs', stats:API_BASE+'/api/stats', refresh:API_BASE+'/api/refresh', update:(id)=>API_BASE+'/api/jobs/'+id+'/update', bulk:API_BASE+'/api/jobs/bulk-update', clRegen:(id)=>API_BASE+'/api/jobs/'+id+'/cover-letter/regenerate' };
const API = { jobs:API_BASE+'/api/jobs', stats:API_BASE+'/api/stats', refresh:API_BASE+'/api/refresh', update:(id)=>API_BASE+'/api/jobs/'+id+'/update', bulk:API_BASE+'/api/jobs/bulk-update', clRegen:(id)=>API_BASE+'/api/jobs/'+id+'/cover-letter/regenerate', marketIntel:API_BASE+'/api/market-intel' };
const STATUSES = ['Backlog','Applied','Phone Screen','Interview','Offer','Accepted','Rejected','Ghosted','Declined'];
const PRIOS = ['','Low','Medium','High','Urgent'];
const SCOLORS = { 'Backlog':'#64748b','Applied':'#3b82f6','Phone Screen':'#8b5cf6','Interview':'#f59e0b','Offer':'#22c55e','Accepted':'#16a34a','Rejected':'#ef4444','Ghosted':'#78716c','Declined':'#6b7280' };
const SORDER = ['Backlog','Applied','Phone Screen','Interview','Offer','Accepted','Rejected','Ghosted','Declined'];
let state = { jobs:[], stats:{}, filtered:[], selected:new Set(), filter:'all', search:'', sort:'date-desc' };
let state = { jobs:[], stats:{}, marketIntel:null, filtered:[], selected:new Set(), filter:'all', search:'', sort:'date-desc', view:'table' };
function notify(m,t) {
const e=document.getElementById('n');
@@ -22,10 +22,23 @@ async function f(url,opts) {
try { const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opts}); return await r.json(); }
catch(e){ notify('API error: '+e.message,'err'); return null; }
}
function computeMatchScore(j) {
let score=50;
if(j.strong_match) score+=20;
if(j.salary) score+=5;
if(j.notes&&j.notes.length>50) score+=10; else if(j.notes) score+=5;
if(j.cover_letter_url) score+=10;
if(j.role&&j.name) score+=5;
if(j.url) score+=5;
return Math.min(score,99);
}
function matchColor(s){ return s>=80?'#22c55e':s>=60?'#22d3ee':s>=40?'#f59e0b':'#ef4444'; }
async function load() {
const [jd,sd]=await Promise.all([f(API.jobs,{}),f(API.stats,{})]);
const [jd,sd,mid]=await Promise.all([f(API.jobs,{}),f(API.stats,{}),f(API.marketIntel,{})]);
if(jd&&jd.jobs) state.jobs=jd.jobs;
if(sd) state.stats=sd;
if(mid) state.marketIntel=mid;
filter(); render();
}
async function upd(id,up) { const r=await f(API.update(id),{method:'POST',body:JSON.stringify(up)}); if(r&&r.ok){notify('Saved','ok');load();}else notify('Save failed','err'); }
@@ -59,7 +72,7 @@ function render() {
const total=state.jobs.length||1, counts={}; state.jobs.forEach(j=>{counts[j.status]=(counts[j.status]||0)+1;});
document.getElementById('funnel').innerHTML=SORDER.map(s=>{const c=counts[s]||0; if(!c)return''; return'<div class="fi"><div class="fl"><span>'+s+'</span><span class="fc">'+c+'</span></div><div class="ftr"><div class="ff" style="width:'+(c/total*100).toFixed(0)+'%;background:'+(SCOLORS[s]||'#64748b')+'"></div></div></div>';}).join('');
const tb=document.getElementById('tb');
if(!state.filtered.length){ tb.innerHTML='<tr><td colspan="11" style="text-align:center;padding:48px 0;color:#64748b">No jobs match this filter</td></tr>'; return; }
if(!state.filtered.length){ tb.innerHTML='<tr><td colspan="12" style="text-align:center;padding:48px 0;color:#64748b">No jobs match this filter</td></tr>'; return; }
tb.innerHTML=state.filtered.map(j=>{
const sel=state.selected.has(j.id)?'checked':'';
const sCol=SCOLORS[j.status]||'#64748b', sal=j.salary?'$'+Number(j.salary).toLocaleString():'—', age=(j.days_since||0)+'d';
@@ -80,7 +93,8 @@ function render() {
const rowCls=[stale,clsCls].filter(Boolean).join(' ');
const name=j.url?'<a href="'+j.url+'" target="_blank">'+j.name+'</a>':j.name;
const mb=j.strong_match?'<span class="badge badge-sm">★</span>':'';
return '<tr class="'+rowCls+'" data-id="'+j.id+'"><td class="c-chk"><input type="checkbox" '+sel+' onchange="tog(\''+j.id+'\')"></td><td class="c-co">'+name+mb+'</td><td class="c-ro">'+(j.role||"—")+'</td><td class="c-st"><span class="stat-pill" style="background:'+sCol+'18;color:'+sCol+';border-color:'+sCol+'40" onclick="showMenu(event,\''+j.id+'\',\''+j.status+'\')">'+j.status+'</span></td><td class="c-lo">'+(j.location||"—")+'</td><td class="c-sr">'+(j.source||"—")+'</td><td class="c-sa">'+sal+'</td><td class="c-cls">'+closeHtml+'</td><td class="c-ag">'+age+'</td><td class="c-cl">'+(j.cover_letter_url?'<a href="'+j.cover_letter_url+'" target="_blank" class="cl-link" title="View cover letter">📝</a><button class="cl-rg" onclick="regenerateCL(\''+j.id+'\')" title="Regenerate">🔄</button>':'<button class="cl-rg" onclick="regenerateCL(\''+j.id+'\')" title="Generate cover letter">+CL</button>')+'</td><td class="c-no"><button class="nb" onclick="editNote(\''+j.id+'\')">'+(j.notes?'✎':'+')+'</button></td></tr>';
const ms=computeMatchScore(j), mc=matchColor(ms);
return '<tr class="'+rowCls+'" data-id="'+j.id+'"><td class="c-chk"><input type="checkbox" '+sel+' onchange="tog(\''+j.id+'\')"></td><td class="c-co">'+name+mb+'</td><td class="c-ro">'+(j.role||"—")+'</td><td class="c-st"><span class="stat-pill" style="background:'+sCol+'18;color:'+sCol+';border-color:'+sCol+'40" onclick="showMenu(event,\''+j.id+'\',\''+j.status+'\')">'+j.status+'</span></td><td class="c-lo">'+(j.location||"—")+'</td><td class="c-sr">'+(j.source||"—")+'</td><td class="c-sa">'+sal+'</td><td class="c-cls">'+closeHtml+'</td><td class="c-ag">'+age+'</td><td class="c-mt"><span class="match-pill" style="color:'+mc+';border-color:'+mc+'40" onclick="showMatchDetail(\''+j.id+'\',\''+ms+'\')">'+ms+'%</span></td><td class="c-cl">'+(j.cover_letter_url?'<a href="'+j.cover_letter_url+'" target="_blank" class="cl-link" title="View cover letter">📝</a><button class="cl-rg" onclick="regenerateCL(\''+j.id+'\')" title="Regenerate">🔄</button>':'<button class="cl-rg" onclick="regenerateCL(\''+j.id+'\')" title="Generate cover letter">+CL</button>')+'</td><td class="c-no"><button class="nb" onclick="editNote(\''+j.id+'\')">'+(j.notes?'✎':'+')+'</button></td></tr>';
}).join('');
document.getElementById('rc').textContent=state.filtered.length+' of '+state.jobs.length;
document.getElementById('bc').textContent=state.selected.size;
@@ -92,6 +106,7 @@ function selA(){ state.filtered.forEach(j=>state.selected.add(j.id)); render();
function desel(){ state.selected.clear(); render(); }
function setFilter(f){
state.filter=f;
if(state.view!=='table') setView('table');
// Update tab active states
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('a',t.dataset.f===f));
// Update KPI card active states
@@ -137,6 +152,84 @@ function toggleTheme(){
b.textContent=isLight?'🌙':'☀️';
localStorage.setItem('theme',isLight?'light':'dark');
}
// ── View switching ──────────────────────────────────────────────────────────
function setView(v){
state.view=v;
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('a',t.dataset.v===v));
document.getElementById('table-view').style.display=v==='table'?'block':'none';
document.getElementById('intel-view').style.display=v==='intel'?'block':'none';
if(v==='intel'&&state.marketIntel) renderIntel();
else if(v==='intel'&&!state.marketIntel) loadIntel();
}
async function loadIntel(){
const d=await f(API.marketIntel,{});
if(d){ state.marketIntel=d; renderIntel(); }
}
function showMatchDetail(id,score){
const j=state.jobs.find(x=>x.id===id);
if(!j) return;
let signals=[];
if(j.strong_match) signals.push('✓ Strong match flagged');
if(j.salary) signals.push('✓ Salary: $'+Number(j.salary).toLocaleString());
if(j.notes&&j.notes.length>50) signals.push('✓ Detailed JD summary');
else if(j.notes) signals.push('✓ Has JD notes');
if(j.cover_letter_url) signals.push('✓ Cover letter exists');
if(j.role) signals.push('✓ Role: '+j.role);
if(j.url) signals.push('✓ Job URL on file');
if(signals.length===0) signals=['No match signals yet — add notes or flag as strong match'];
document.getElementById('md-score').textContent=score+'%';
document.getElementById('md-signals').innerHTML=signals.map(s=>'<div class="md-sig">'+s+'</div>').join('');
document.getElementById('md-name').textContent=j.name+' — '+(j.role||'');
document.getElementById('md-overlay').style.display='flex';
}
function closeMatchDetail(){ document.getElementById('md-overlay').style.display='none'; }
// ── Market Intel rendering ───────────────────────────────────────────────────
function renderIntel(){
const mi=state.marketIntel;
if(!mi) return;
document.getElementById('mi-total').textContent=mi.total_tracked||0;
document.getElementById('mi-avg').textContent=mi.avg_salary?'$'+Number(mi.avg_salary).toLocaleString():'—';
document.getElementById('mi-median').textContent=mi.median_salary?'$'+Number(mi.median_salary).toLocaleString():'—';
document.getElementById('mi-range').textContent=(mi.min_salary&&mi.max_salary)?'$'+Number(mi.min_salary).toLocaleString()+' $'+Number(mi.max_salary).toLocaleString():'—';
document.getElementById('mi-with-sal').textContent=mi.with_salary+' of '+mi.total_tracked;
document.getElementById('mi-refreshed').textContent=mi.refreshed_at?new Date(mi.refreshed_at).toLocaleString('en-AU',{timeZone:'Australia/Perth'}):'';
// Salary distribution
const dist=mi.salary_distribution||[];
const maxCount=Math.max(...dist.map(d=>d.count),1);
document.getElementById('mi-dist').innerHTML=dist.map(d=>{
const pct=(d.count/maxCount*100).toFixed(0);
const fill=parseInt(d.label.replace(/[^0-9]/g,''))||0;
const hue=fill<80?200:fill<100?170:fill<120?140:fill<140?100:50;
return '<div class="mi-bar-row"><span class="mi-bar-label">'+d.label+'</span><div class="mi-bar-track"><div class="mi-bar-fill" style="width:'+pct+'%;background:hsl('+hue+',60%,50%)"></div></div><span class="mi-bar-count">'+d.count+'</span></div>';
}).join('');
// Location stats
const locs=mi.by_location||{};
const locEntries=Object.entries(locs).sort((a,b)=>b[1].count-a[1].count);
document.getElementById('mi-locs').innerHTML=locEntries.map(([loc,d])=>{
return '<div class="mi-loc-row"><span class="mi-loc-name">'+(loc||'Unknown')+'</span><span class="mi-loc-count">'+d.count+' jobs</span><span class="mi-loc-sal">$'+Number(d.avg).toLocaleString()+' avg</span></div>';
}).join('')||'<div style="color:var(--m);font-size:12px;padding:8px 0">No salary data by location</div>';
// Monthly trend
const months=mi.monthly_trend||{};
const monthEntries=Object.entries(months);
const maxMonth=Math.max(...monthEntries.map(m=>m[1]),1);
document.getElementById('mi-months').innerHTML=monthEntries.map(([m,c])=>{
const pct=(c/maxMonth*100).toFixed(0);
const label=m.slice(5)+'/'+m.slice(2,4);
return '<div class="mi-bar-row"><span class="mi-bar-label">'+label+'</span><div class="mi-bar-track"><div class="mi-bar-fill" style="width:'+pct+'%;background:hsl(190,70%,45%)"></div></div><span class="mi-bar-count">'+c+'</span></div>';
}).join('')||'<div style="color:var(--m);font-size:12px;padding:8px 0">No date data yet</div>';
// Source breakdown
const srcs=mi.by_source||{};
document.getElementById('mi-srcs').innerHTML=Object.entries(srcs).sort((a,b)=>b[1].count-a[1].count).map(([src,d])=>{
return '<div class="mi-loc-row"><span class="mi-loc-name">'+(src||'Unknown')+'</span><span class="mi-loc-count">'+d.count+' jobs</span><span class="mi-loc-sal">'+(d.avg_salary?'$'+Number(d.avg_salary).toLocaleString():'—')+'</span></div>';
}).join('');
}
</script>
<style>
:root{--bg:#0b1120;--srf:#141d30;--srf2:#1e2a45;--bdr:#1e3050;--txt:#e2e8f0;--m:#64748b;--cy:#22d3ee;--gr:#22c55e;--am:#f59e0b;--rd:#ef4444;--cz:#0b1120;--r-hover:rgba(34,211,238,0.02);--r-td:rgba(30,46,80,0.3)}
@@ -208,6 +301,40 @@ tr:hover td{background:var(--r-hover)}
.sm-item{padding:6px 10px;border-radius:4px;cursor:pointer;font-size:12px}
.sm-item:hover{background:var(--srf2)} .sm-cur{background:var(--cy);color:var(--cz);font-weight:600}
.ft{text-align:center;padding:20px 0 12px;color:var(--m);font-size:10px} .ft a{color:var(--cy);text-decoration:none}
.match-pill{display:inline-block;padding:1px 6px;border-radius:4px;font-size:11px;font-weight:600;border:1px solid;cursor:pointer}
.match-pill:hover{filter:brightness(1.3)}
.c-mt{width:50px;text-align:center}
/* Market Intel panel */
.intel-panel{display:none;background:var(--srf);border-radius:8px;border:1px solid var(--bdr);padding:16px;margin-bottom:18px}
.intel-panel h2{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--cy);margin-bottom:14px}
.intel-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-bottom:18px}
.intel-card{background:var(--srf2);border-radius:6px;padding:10px;text-align:center}
.intel-card .val{font-size:20px;font-weight:700;color:var(--txt);line-height:1.2}
.intel-card .lbl{font-size:10px;color:var(--m);margin-top:2px}
.intel-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
@media(max-width:700px){.intel-grid-2{grid-template-columns:1fr}}
.intel-section{margin-bottom:16px}
.intel-section h3{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--m);margin-bottom:8px}
.mi-bar-row{display:flex;align-items:center;gap:8px;margin-bottom:4px;font-size:12px}
.mi-bar-label{width:95px;text-align:right;color:var(--m);flex-shrink:0;font-size:11px}
.mi-bar-track{flex:1;height:18px;background:var(--srf2);border-radius:3px;overflow:hidden}
.mi-bar-fill{height:100%;border-radius:3px;transition:width .5s}
.mi-bar-count{width:28px;text-align:right;color:var(--txt);font-weight:500;font-size:11px}
.mi-loc-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--r-td);font-size:12px}
.mi-loc-row:last-child{border:none}
.mi-loc-name{flex:1;color:var(--txt)}
.mi-loc-count{color:var(--m);font-size:11px;white-space:nowrap}
.mi-loc-sal{color:var(--cy);font-size:11px;font-weight:500;text-align:right;min-width:80px}
/* Match detail overlay */
.md-overlay{display:none;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.6);align-items:center;justify-content:center}
.md-card{background:var(--srf);border:1px solid var(--bdr);border-radius:10px;padding:20px;max-width:400px;width:90%;max-height:80vh;overflow-y:auto}
.md-card h3{font-size:14px;font-weight:600;margin-bottom:4px}
.md-card .sub{font-size:12px;color:var(--m);margin-bottom:12px}
.md-score-big{font-size:40px;font-weight:800;text-align:center;padding:10px 0;letter-spacing:-.03em}
.md-signals{font-size:12px;line-height:1.8}
.md-sig{padding:3px 0;color:var(--txt)}
.md-close{display:block;width:100%;padding:8px;margin-top:14px;background:var(--srf2);border:1px solid var(--bdr);border-radius:6px;color:var(--m);font-size:12px;cursor:pointer;text-align:center}
.md-close:hover{color:var(--txt);border-color:var(--cy)}
@media(max-width:768px){.c{padding:10px}.k{grid-template-columns:repeat(2,1fr)}.sbox{width:140px}.c-lo,.c-sr,.c-sa{display:none}}
</style>
</head>
@@ -217,14 +344,57 @@ tr:hover td{background:var(--r-hover)}
<div class="k"><div class="kc cy" onclick="setFilter('all')" style="cursor:pointer"><div class="kv" id="kt"></div><div class="kl">Total Jobs</div></div><div class="kc gr" id="kf-strong" onclick="setFilter('__strong')" style="cursor:pointer"><div class="kv" id="km"></div><div class="kl">Strong Matches</div></div><div class="kc am"><div class="kv" id="kw"></div><div class="kl">This Week</div></div><div class="kc rd"><div class="kv" id="ks"></div><div class="kl">Stale (14d+)</div></div><div class="kc" id="kf-urgent" onclick="setFilter('__urgent')" style="border-color:#f59e0b;cursor:pointer"><div class="kv" id="ku"></div><div class="kl" style="color:var(--am)">Expiring Soon</div></div><div class="kc" id="kf-past" onclick="setFilter('__past')" style="border-color:#64748b;cursor:pointer"><div class="kv" id="kp"></div><div class="kl" style="color:var(--m)">Past Closing</div></div></div>
<div class="fu" id="funnel"></div>
<div class="bulk" id="bb"><span><strong id="bc">0</strong> selected</span><span>Status:</span><select id="bs"></select><button class="b-btn" onclick="applyBulk()">Apply</button><button class="b-btn2" onclick="desel()">Clear</button></div>
<div class="ct"><div class="tabs" id="tabs"><span class="tab a" data-f="all" onclick="setFilter('all')">All</span><span class="tab" data-f="Backlog" onclick="setFilter('Backlog')">Backlog</span><span class="tab" data-f="Applied" onclick="setFilter('Applied')">Applied</span><span class="tab" data-f="Interview" onclick="setFilter('Interview')">Interviews</span><span class="tab" data-f="Rejected" onclick="setFilter('Rejected')">Rejected</span></div>
<div class="ct"><div class="tabs" id="tabs"><span class="tab a" data-v="table" onclick="setView('table')">📋 All Jobs</span><span class="tab" data-v="intel" onclick="setView('intel')">📊 Market Intel</span><span class="tab" data-f="all" onclick="setFilter('all')">All</span><span class="tab" data-f="Backlog" onclick="setFilter('Backlog')">Backlog</span><span class="tab" data-f="Applied" onclick="setFilter('Applied')">Applied</span><span class="tab" data-f="Interview" onclick="setFilter('Interview')">Interviews</span><span class="tab" data-f="Rejected" onclick="setFilter('Rejected')">Rejected</span></div>
<input class="sbox" id="s" placeholder="Search..." oninput="doSearch()">
<div class="sg"><span class="sort-btn a" data-s="date-desc" onclick="setSort('date-desc')">Newest</span><span class="sort-btn" data-s="days-desc" onclick="setSort('days-desc')">Stalest</span><span class="sort-btn" data-s="salary-desc" onclick="setSort('salary-desc')">Salary</span></div></div>
<div class="tw"><div class="th"><h2>Opportunities</h2><span class="rc" id="rc"></span></div>
<div class="ts"><table><thead><tr><th><input type="checkbox" onchange="if(this.checked)selA();else desel();"></th><th>Company</th><th>Role</th><th>Status</th><th>Location</th><th>Source</th><th style="text-align:right">Salary</th><th style="text-align:right">Closing</th><th style="text-align:right">Age</th><th>CL</th><th></th></tr></thead>
<tbody id="tb"><tr><td colspan="11" style="text-align:center;padding:48px 0;color:var(--m)">Loading...</td></tr></tbody></table></div></div>
<div class="ft">The Rhino in the Green Jacket · <a href="/api/jobs">API</a></div></div>
<div id="table-view"><div class="tw"><div class="th"><h2>Opportunities</h2><span class="rc" id="rc"></span></div>
<div class="ts"><table><thead><tr><th><input type="checkbox" onchange="if(this.checked)selA();else desel();"></th><th>Company</th><th>Role</th><th>Status</th><th>Location</th><th>Source</th><th style="text-align:right">Salary</th><th style="text-align:right">Closing</th><th style="text-align:right">Age</th><th style="text-align:center">Match</th><th>CL</th><th></th></tr></thead>
<tbody id="tb"><tr><td colspan="12" style="text-align:center;padding:48px 0;color:var(--m)">Loading...</td></tr></tbody></table></div></div></div>
<!-- Market Intel Panel -->
<div id="intel-view" class="intel-panel">
<h2>📊 Market Intel</h2>
<div class="intel-grid">
<div class="intel-card"><div class="val" id="mi-total"></div><div class="lbl">Total Tracked</div></div>
<div class="intel-card"><div class="val" id="mi-avg"></div><div class="lbl">Avg Salary</div></div>
<div class="intel-card"><div class="val" id="mi-median"></div><div class="lbl">Median Salary</div></div>
<div class="intel-card"><div class="val" id="mi-range"></div><div class="lbl">Salary Range</div></div>
<div class="intel-card"><div class="val" id="mi-with-sal"></div><div class="lbl">With Salary Data</div></div>
</div>
<div class="intel-grid-2">
<div class="intel-section">
<h3>Salary Distribution</h3>
<div id="mi-dist"></div>
</div>
<div class="intel-section">
<h3>By Location</h3>
<div id="mi-locs"></div>
</div>
</div>
<div class="intel-grid-2">
<div class="intel-section">
<h3>Monthly Trend (jobs added)</h3>
<div id="mi-months"></div>
</div>
<div class="intel-section">
<h3>By Source</h3>
<div id="mi-srcs"></div>
</div>
</div>
<div style="text-align:center;color:var(--m);font-size:10px;padding-top:8px">Updated from Notion · <span id="mi-refreshed"></span></div>
</div>
<div class="ft">The Rhino in the Green Jacket · <a href="/api/jobs">API</a> · <a href="/ops">Ops</a></div></div>
<div id="sm" class="sm"></div>
<!-- Match Detail Overlay -->
<div id="md-overlay" class="md-overlay" onclick="if(event.target===this)closeMatchDetail()">
<div class="md-card">
<h3 id="md-name"></h3>
<div class="sub">Match Analysis</div>
<div class="md-score-big" style="color:var(--gr)" id="md-score"></div>
<div class="md-signals" id="md-signals"></div>
<button class="md-close" onclick="closeMatchDetail()">Close</button>
</div>
</div>
<script>
// Populate bulk status dropdown after STATUSES is defined
document.getElementById('bs').innerHTML=STATUSES.map(s=>'<option value="'+s+'">'+s+'</option>').join('');