402 lines
30 KiB
HTML
402 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<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', 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:{}, marketIntel:null, filtered:[], selected:new Set(), filter:'all', search:'', sort:'date-desc', view:'table' };
|
||
|
||
function notify(m,t) {
|
||
const e=document.getElementById('n');
|
||
e.textContent=m; e.className='n '+(t||'ok'); e.style.display='block';
|
||
setTimeout(()=>e.style.display='none',3500);
|
||
}
|
||
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,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'); }
|
||
async function bulk(ids,up) { if(!ids.length)return; const r=await f(API.bulk,{method:'POST',body:JSON.stringify({ids,updates:up})}); if(r&&r.ok){notify('Updated '+(r.results?r.results.filter(x=>x.ok).length+' jobs':'')+'','ok');load();} }
|
||
async function refresh() { const r=await f(API.refresh,{method:'POST'}); if(r&&r.ok){notify('Refreshed — '+r.total+' jobs','ok');load();} }
|
||
|
||
function filter() {
|
||
let fi=[...state.jobs];
|
||
if(state.filter==='__urgent'){ fi=fi.filter(j=>j.closing_status==='urgent'); }
|
||
else if(state.filter==='__past'){ fi=fi.filter(j=>j.closing_status==='past'); }
|
||
else if(state.filter==='__strong'){ fi=fi.filter(j=>j.strong_match); }
|
||
else if(state.filter!=='all') fi=fi.filter(j=>j.status===state.filter);
|
||
if(state.search){ const q=state.search.toLowerCase(); fi=fi.filter(j=>(j.name+(j.role||'')+(j.location||'')+(j.notes||'')).toLowerCase().includes(q)); }
|
||
const[field,dir]=state.sort.split('-');
|
||
fi.sort((a,b)=>{ let va,vb; switch(field){
|
||
case'date': va=a.date_added||'0'; vb=b.date_added||'0'; break;
|
||
case'days': va=a.days_since||0; vb=b.days_since||0; break;
|
||
case'name': va=(a.name||'').toLowerCase(); vb=(b.name||'').toLowerCase(); break;
|
||
case'salary': va=a.salary||0; vb=b.salary||0; break;
|
||
default: va=a.date_added||'0'; vb=b.date_added||'0';
|
||
} return va<vb?dir==='asc'?-1:1:va>vb?dir==='asc'?1:-1:0; });
|
||
state.filtered=fi; state.selected=new Set();
|
||
}
|
||
|
||
function render() {
|
||
const st=state.stats||{};
|
||
document.getElementById('kt').textContent=st.total||0; document.getElementById('km').textContent=st.strong_matches||0;
|
||
document.getElementById('kw').textContent=st.this_week||0; document.getElementById('ks').textContent=st.stale_14d||0;
|
||
document.getElementById('ku').textContent=st.closing_urgent||0; document.getElementById('kp').textContent=st.closing_past||0;
|
||
document.getElementById('kr').textContent=st.refreshed_at?new Date(st.refreshed_at).toLocaleString('en-AU',{timeZone:'Australia/Perth'}):'-';
|
||
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="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';
|
||
const stale=(j.days_since||0)>14?'stale-14':(j.days_since||0)>7?'stale-7':'';
|
||
// Closing date urgency
|
||
let closeHtml = '—', clsCls = '';
|
||
if(j.closing_date){
|
||
const cd=new Date(j.closing_date);
|
||
const now=new Date();
|
||
const days=Math.round((cd-now)/86400000);
|
||
const ds=j.closing_date.split('-');
|
||
const fmt=ds[2]+'/'+ds[1];
|
||
closeHtml=fmt;
|
||
if(j.closing_status==='past'){ clsCls='cls-past'; }
|
||
else if(j.closing_status==='urgent'){ clsCls='cls-urgent'; }
|
||
else if(j.closing_status==='warning'){ clsCls='cls-warn'; }
|
||
}
|
||
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>':'';
|
||
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;
|
||
document.getElementById('bb').style.display=state.selected.size?'flex':'none';
|
||
}
|
||
|
||
function tog(id){ state.selected.has(id)?state.selected.delete(id):state.selected.add(id); render(); }
|
||
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
|
||
document.getElementById('kf-strong').style.borderColor=f==='__strong'?'var(--cy)':'';
|
||
document.getElementById('kf-urgent').style.borderColor=f==='__urgent'?'var(--cy)':'#f59e0b';
|
||
document.getElementById('kf-past').style.borderColor=f==='__past'?'var(--cy)':'#64748b';
|
||
filter(); render();
|
||
}
|
||
function doSearch(){ state.search=document.getElementById('s').value; filter(); render(); }
|
||
function setSort(s){ state.sort=s; document.querySelectorAll('.sort-btn').forEach(b=>b.classList.toggle('a',b.dataset.s===s)); filter(); render(); }
|
||
function applyBulk(){ const v=document.getElementById('bs').value; bulk([...state.selected],{status:v}); }
|
||
function showMenu(ev,id,cur){
|
||
const m=document.getElementById('sm');
|
||
m.innerHTML=STATUSES.map(s=>'<div class="sm-item'+(s===cur?' sm-cur':'')+'" onclick="changeStat(\''+id+'\',\''+s+'\')">'+s+'</div>').join('');
|
||
m.style.display='block'; m.style.left=Math.min(ev.clientX,window.innerWidth-200)+'px'; m.style.top=ev.clientY+'px';
|
||
setTimeout(()=>document.addEventListener('click',function(){m.style.display='none';},{once:true}),10);
|
||
}
|
||
async function changeStat(id,s){ await upd(id,{status:s}); document.getElementById('sm').style.display='none'; }
|
||
function editNote(id){ const j=state.jobs.find(x=>x.id===id); const n=prompt('Edit notes / JD summary:',j&&j.notes?j.notes:''); if(n!==null) upd(id,{notes:n}); }
|
||
|
||
async function regenerateCL(id){
|
||
const j=state.jobs.find(x=>x.id===id);
|
||
const hasCL=j&&j.cover_letter_url;
|
||
const defaultFB=hasCL?'Make it stronger':'Generate a professional cover letter for this role';
|
||
const pText=hasCL?'How should the cover letter change? (e.g. "focus on CRM experience", "more professional tone"):':'What should the cover letter emphasise? (e.g. "highlight budget management", "reference government experience"):';
|
||
const fb=prompt(pText,defaultFB);
|
||
if(!fb)return;
|
||
const btn=event&&event.target||document.querySelector('[onclick*="'+id+'"]');
|
||
if(btn)btn.textContent='...';
|
||
const r=await f(API.clRegen(id),{method:'POST',body:JSON.stringify({feedback:fb})});
|
||
if(r&&r.ok){ notify('Cover letter '+(hasCL?'regenerated':'generated')+' ✅','ok'); load(); }
|
||
else{ notify('Generation failed: '+(r&&r.error||'unknown error'),'err'); if(btn)btn.textContent=hasCL?'🔄':'+CL'; }
|
||
}
|
||
document.addEventListener('DOMContentLoaded',function(){
|
||
// Theme init
|
||
if(localStorage.getItem('theme')==='light'){ document.documentElement.classList.add('light'); document.getElementById('themeBtn').textContent='🌙'; }
|
||
load(); setInterval(load,120000);
|
||
});
|
||
function toggleTheme(){
|
||
const h=document.documentElement; const b=document.getElementById('themeBtn');
|
||
h.classList.toggle('light');
|
||
const isLight=h.classList.contains('light');
|
||
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)}
|
||
.light{--bg:#f8fafc;--srf:#ffffff;--srf2:#f1f5f9;--bdr:#e2e8f0;--txt:#0f172a;--cy:#0891b2;--cz:#ffffff;--r-hover:rgba(8,145,178,0.04);--r-td:rgba(15,23,42,0.06)}
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--txt);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;transition:background .25s,color .25s}
|
||
.c{max-width:1440px;margin:0 auto;padding:16px}
|
||
.h{display:flex;justify-content:space-between;align-items:center;padding:8px 0 16px;border-bottom:1px solid var(--bdr);margin-bottom:20px;flex-wrap:wrap;gap:8px}
|
||
.h h1{font-size:18px;font-weight:700;color:var(--txt)}
|
||
.h .sub{font-size:12px;color:var(--m);margin-top:2px}
|
||
.h .rh{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--m)}
|
||
.h .rh button{background:var(--srf2);color:var(--m);border:1px solid var(--srf2);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:11px;font-weight:500}
|
||
.h .rh button:hover{border-color:var(--cy);color:var(--cy)}
|
||
.n{display:none;padding:10px 14px;border-radius:6px;margin-bottom:14px;font-size:13px}
|
||
.n.ok{background:rgba(34,197,94,0.12);color:var(--gr);border:1px solid rgba(34,197,94,0.2)}
|
||
.n.err{background:rgba(239,68,68,0.12);color:var(--rd);border:1px solid rgba(239,68,68,0.2)}
|
||
.k{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-bottom:18px}
|
||
.kc{background:var(--srf);border-radius:8px;padding:14px;border:1px solid var(--bdr)}
|
||
.kc .kv{font-size:26px;font-weight:700;letter-spacing:-0.02em;line-height:1.1}
|
||
.kc .kl{font-size:11px;color:var(--m);margin-top:3px;font-weight:500}
|
||
.kc.cy .kv{color:var(--cy)} .kc.gr .kv{color:var(--gr)} .kc.am .kv{color:var(--am)} .kc.rd .kv{color:var(--rd)}
|
||
.fu{background:var(--srf);border-radius:8px;padding:16px;margin-bottom:18px;border:1px solid var(--bdr)}
|
||
.fi{margin-bottom:5px} .fl{display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px} .fc{color:var(--m)}
|
||
.ftr{height:5px;background:var(--srf2);border-radius:3px;overflow:hidden} .ff{height:100%;border-radius:3px}
|
||
.bulk{display:none;align-items:center;gap:10px;padding:8px 14px;background:rgba(34,211,238,0.06);border:1px solid rgba(34,211,238,0.15);border-radius:6px;margin-bottom:12px;font-size:12px}
|
||
.bulk select{background:var(--srf2);color:var(--txt);border:1px solid var(--bdr);padding:4px 8px;border-radius:4px;font-size:12px}
|
||
.bulk .b-btn{background:var(--cy);color:var(--cz);border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600}
|
||
.bulk .b-btn2{background:var(--srf2);color:var(--m);border:1px solid var(--bdr);padding:5px 12px;border-radius:4px;cursor:pointer;font-size:11px}
|
||
.ct{display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center}
|
||
.tabs{display:flex;gap:3px;flex-wrap:wrap}
|
||
.tab{padding:5px 14px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;background:var(--srf);color:var(--m);border:1px solid transparent}
|
||
.tab:hover{color:var(--txt)} .tab.a{background:var(--cy);color:var(--cz)}
|
||
.sbox{background:var(--srf);border:1px solid var(--bdr);padding:5px 10px;border-radius:6px;color:var(--txt);font-size:12px;width:180px}
|
||
.sbox:focus{outline:none;border-color:var(--cy)}
|
||
.sg{display:flex;gap:2px;margin-left:auto}
|
||
.sort-btn{padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;color:var(--m);border:1px solid transparent}
|
||
.sort-btn:hover{color:var(--txt)} .sort-btn.a{border-color:var(--cy);color:var(--cy)}
|
||
.tw{background:var(--srf);border-radius:8px;overflow:hidden;border:1px solid var(--bdr)}
|
||
.th{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--bdr)}
|
||
.th h2{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--m)}
|
||
.th .rc{color:var(--m);font-size:11px}
|
||
.ts{overflow-x:auto}
|
||
table{width:100%;border-collapse:collapse;font-size:12px}
|
||
th{text-align:left;padding:8px 10px;font-weight:600;color:var(--m);font-size:10px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--bdr);position:sticky;top:0;background:var(--srf);white-space:nowrap}
|
||
td{padding:6px 10px;border-bottom:1px solid var(--r-td);vertical-align:middle}
|
||
tr:hover td{background:var(--r-hover)}
|
||
.stale-14 td{opacity:.65;color:var(--rd)} .stale-7 td{opacity:.8;color:var(--am)}
|
||
/* Closing date urgency */
|
||
.cls-past td{opacity:0.4}
|
||
.cls-urgent .c-cls{color:var(--rd);font-weight:700}
|
||
.cls-warn .c-cls{color:var(--am)}
|
||
.c-chk{width:28px} .c-co{min-width:160px} .c-co a{color:var(--cy);text-decoration:none;font-weight:500}
|
||
.c-co a:hover{text-decoration:underline} .c-ro{min-width:170px;color:var(--m)} .c-lo{min-width:100px;color:var(--m)}
|
||
.c-sr{min-width:65px;color:var(--m)} .c-sa{text-align:right;white-space:nowrap} .c-cls{text-align:right;white-space:nowrap;font-size:11px}
|
||
.c-ag{text-align:right;white-space:nowrap;color:var(--m)}
|
||
.c-no{width:30px;text-align:center}
|
||
.c-cl{width:55px;text-align:center;white-space:nowrap}
|
||
.cl-link{text-decoration:none;font-size:15px;cursor:pointer;margin-right:4px}
|
||
.cl-link:hover{filter:brightness(1.3)}
|
||
.cl-rg{background:none;border:none;color:var(--m);cursor:pointer;font-size:11px;padding:2px 5px;border-radius:4px}
|
||
.cl-rg:hover{background:var(--srf2);color:var(--cy)}
|
||
.stat-pill{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500;cursor:pointer;border:1px solid transparent}
|
||
.stat-pill:hover{filter:brightness(1.2)}
|
||
.nb{background:none;border:none;color:var(--m);cursor:pointer;font-size:13px;padding:2px 4px;border-radius:3px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center}
|
||
.nb:hover{background:var(--srf2);color:var(--txt)}
|
||
.badge{display:inline-block;font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;margin-left:4px}
|
||
.badge-sm{background:rgba(34,211,238,0.12);color:var(--cy)}
|
||
.sm{display:none;position:fixed;z-index:100;background:var(--srf);border:1px solid var(--bdr);border-radius:6px;padding:3px;box-shadow:0 8px 16px rgba(0,0,0,.3);min-width:140px}
|
||
.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>
|
||
<body><div class="c">
|
||
<div class="h"><div><h1>Job Tracker</h1><div class="sub">Anthony Martin · Perth, WA</div></div><div class="rh"><span id="kr">—</span><button onclick="toggleTheme()" id="themeBtn" title="Toggle light/dark mode">☀️</button><button onclick="refresh()">↻ Refresh</button></div></div>
|
||
<div id="n" class="n"></div>
|
||
<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-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 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('');
|
||
</script>
|
||
</body></html> |