Phase 2: Market Intel + Smart Match + Ops Timeline
This commit is contained in:
190
index.html
190
index.html
@@ -6,12 +6,12 @@
|
|||||||
<title>Job Tracker · Anthony</title>
|
<title>Job Tracker · Anthony</title>
|
||||||
<script>
|
<script>
|
||||||
const API_BASE = (window.location.pathname.replace(/\/index\.html$/,'').replace(/\/+$/,'')) || '';
|
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 STATUSES = ['Backlog','Applied','Phone Screen','Interview','Offer','Accepted','Rejected','Ghosted','Declined'];
|
||||||
const PRIOS = ['','Low','Medium','High','Urgent'];
|
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 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'];
|
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) {
|
function notify(m,t) {
|
||||||
const e=document.getElementById('n');
|
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(); }
|
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; }
|
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() {
|
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(jd&&jd.jobs) state.jobs=jd.jobs;
|
||||||
if(sd) state.stats=sd;
|
if(sd) state.stats=sd;
|
||||||
|
if(mid) state.marketIntel=mid;
|
||||||
filter(); render();
|
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 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;});
|
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('');
|
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');
|
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=>{
|
tb.innerHTML=state.filtered.map(j=>{
|
||||||
const sel=state.selected.has(j.id)?'checked':'';
|
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 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 rowCls=[stale,clsCls].filter(Boolean).join(' ');
|
||||||
const name=j.url?'<a href="'+j.url+'" target="_blank">'+j.name+'</a>':j.name;
|
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 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('');
|
}).join('');
|
||||||
document.getElementById('rc').textContent=state.filtered.length+' of '+state.jobs.length;
|
document.getElementById('rc').textContent=state.filtered.length+' of '+state.jobs.length;
|
||||||
document.getElementById('bc').textContent=state.selected.size;
|
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 desel(){ state.selected.clear(); render(); }
|
||||||
function setFilter(f){
|
function setFilter(f){
|
||||||
state.filter=f;
|
state.filter=f;
|
||||||
|
if(state.view!=='table') setView('table');
|
||||||
// Update tab active states
|
// Update tab active states
|
||||||
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('a',t.dataset.f===f));
|
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('a',t.dataset.f===f));
|
||||||
// Update KPI card active states
|
// Update KPI card active states
|
||||||
@@ -137,6 +152,84 @@ function toggleTheme(){
|
|||||||
b.textContent=isLight?'🌙':'☀️';
|
b.textContent=isLight?'🌙':'☀️';
|
||||||
localStorage.setItem('theme',isLight?'light':'dark');
|
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>
|
</script>
|
||||||
<style>
|
<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)}
|
: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{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}
|
.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}
|
.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}}
|
@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>
|
</style>
|
||||||
</head>
|
</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="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="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="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()">
|
<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="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 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>CL</th><th></th></tr></thead>
|
<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="11" style="text-align:center;padding:48px 0;color:var(--m)">Loading...</td></tr></tbody></table></div></div>
|
<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>
|
||||||
<div class="ft">The Rhino in the Green Jacket · <a href="/api/jobs">API</a></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>
|
<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>
|
<script>
|
||||||
// Populate bulk status dropdown after STATUSES is defined
|
// Populate bulk status dropdown after STATUSES is defined
|
||||||
document.getElementById('bs').innerHTML=STATUSES.map(s=>'<option value="'+s+'">'+s+'</option>').join('');
|
document.getElementById('bs').innerHTML=STATUSES.map(s=>'<option value="'+s+'">'+s+'</option>').join('');
|
||||||
|
|||||||
@@ -253,6 +253,104 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
|||||||
data = refresh_cache(force=True)
|
data = refresh_cache(force=True)
|
||||||
return self._json_response({"ok": True, "total": data["total"]})
|
return self._json_response({"ok": True, "total": data["total"]})
|
||||||
|
|
||||||
|
# GET /api/market-intel — salary distribution + trend analysis
|
||||||
|
elif path == "/api/market-intel":
|
||||||
|
data = refresh_cache()
|
||||||
|
jobs = data.get("jobs", [])
|
||||||
|
salaries = [j["salary"] for j in jobs if j["salary"]]
|
||||||
|
|
||||||
|
brackets = [
|
||||||
|
{"label": "Under $80k", "min": 0, "max": 80000},
|
||||||
|
{"label": "$80k–$100k", "min": 80000, "max": 100000},
|
||||||
|
{"label": "$100k–$120k", "min": 100000, "max": 120000},
|
||||||
|
{"label": "$120k–$140k", "min": 120000, "max": 140000},
|
||||||
|
{"label": "$140k+", "min": 140000, "max": 999999},
|
||||||
|
]
|
||||||
|
distribution = [{"label": b["label"], "count": sum(1 for s in salaries if b["min"] <= s < b["max"])} for b in brackets]
|
||||||
|
|
||||||
|
locs = {}
|
||||||
|
for j in jobs:
|
||||||
|
loc = j["location"] or "Unknown"
|
||||||
|
if j["salary"]:
|
||||||
|
locs.setdefault(loc, []).append(j["salary"])
|
||||||
|
loc_stats = {loc: {"avg": round(sum(s)/len(s)), "min": min(s), "max": max(s), "count": len(s)} for loc, s in locs.items()}
|
||||||
|
|
||||||
|
months = {}
|
||||||
|
for j in jobs:
|
||||||
|
if j["date_added"]:
|
||||||
|
m = j["date_added"][:7]
|
||||||
|
months[m] = months.get(m, 0) + 1
|
||||||
|
|
||||||
|
sources = {}
|
||||||
|
for j in jobs:
|
||||||
|
src = j["source"] or "Unknown"
|
||||||
|
s = sources.setdefault(src, {"count": 0, "salary_sum": 0, "salary_count": 0})
|
||||||
|
s["count"] += 1
|
||||||
|
if j["salary"]:
|
||||||
|
s["salary_sum"] += j["salary"]
|
||||||
|
s["salary_count"] += 1
|
||||||
|
src_stats = {src: {"count": d["count"], "avg_salary": round(d["salary_sum"]/d["salary_count"]) if d["salary_count"] else None} for src, d in sources.items()}
|
||||||
|
|
||||||
|
statuses = {}
|
||||||
|
for j in jobs:
|
||||||
|
s = j["status"]
|
||||||
|
statuses[s] = statuses.get(s, 0) + 1
|
||||||
|
|
||||||
|
return self._json_response({
|
||||||
|
"total_tracked": len(jobs),
|
||||||
|
"with_salary": len(salaries),
|
||||||
|
"avg_salary": round(sum(salaries)/len(salaries)) if salaries else 0,
|
||||||
|
"median_salary": sorted(salaries)[len(salaries)//2] if salaries else 0,
|
||||||
|
"min_salary": min(salaries) if salaries else 0,
|
||||||
|
"max_salary": max(salaries) if salaries else 0,
|
||||||
|
"salary_distribution": distribution,
|
||||||
|
"by_location": loc_stats,
|
||||||
|
"monthly_trend": dict(sorted(months.items())),
|
||||||
|
"by_source": src_stats,
|
||||||
|
"by_status": statuses,
|
||||||
|
"refreshed_at": data.get("refreshed_at", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# GET /api/jobs/<id>/match — match analysis for a job
|
||||||
|
match_job_match = re.match(r"^/api/jobs/([a-f0-9\-]+)/match$", path)
|
||||||
|
if match_job_match:
|
||||||
|
job_id = match_job_match.group(1)
|
||||||
|
data = refresh_cache()
|
||||||
|
job = next((j for j in data.get("jobs", []) if j["id"] == job_id), None)
|
||||||
|
if not job:
|
||||||
|
return self._json_response({"error": "Job not found"}, 404)
|
||||||
|
score = 50
|
||||||
|
signals = []
|
||||||
|
if job.get("strong_match"):
|
||||||
|
score += 20; signals.append("Strong match flag")
|
||||||
|
if job.get("salary"):
|
||||||
|
score += 5; signals.append("Has salary data")
|
||||||
|
if job.get("notes") and len(job.get("notes", "")) > 50:
|
||||||
|
score += 10; signals.append("Detailed JD summary")
|
||||||
|
elif job.get("notes"):
|
||||||
|
score += 5; signals.append("Has JD summary")
|
||||||
|
if job.get("cover_letter_url"):
|
||||||
|
score += 10; signals.append("Cover letter exists")
|
||||||
|
if job.get("role") and job.get("name"):
|
||||||
|
score += 5; signals.append("Company + role populated")
|
||||||
|
if job.get("url"):
|
||||||
|
score += 5; signals.append("Job URL available")
|
||||||
|
score = min(score, 99)
|
||||||
|
return self._json_response({
|
||||||
|
"score": score,
|
||||||
|
"signals": signals,
|
||||||
|
"strong_match": job.get("strong_match", False),
|
||||||
|
"has_cl": bool(job.get("cover_letter_url")),
|
||||||
|
"has_salary": bool(job.get("salary")),
|
||||||
|
"has_notes": bool(job.get("notes")),
|
||||||
|
"job_id": job_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# GET /ops — serve the ops timeline page
|
||||||
|
elif path == "/ops":
|
||||||
|
self.path = "/ops.html"
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
# Serve draft files (cover letters) — preserve extension for browser rendering
|
# Serve draft files (cover letters) — preserve extension for browser rendering
|
||||||
# Support both /drafts/ and /jobs/drafts/ paths (Tailscale Serve uses /jobs/drafts/ from prompt)
|
# Support both /drafts/ and /jobs/drafts/ paths (Tailscale Serve uses /jobs/drafts/ from prompt)
|
||||||
drafts_path = None
|
drafts_path = None
|
||||||
|
|||||||
155
ops.html
Normal file
155
ops.html
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1">
|
||||||
|
<title>Ops Timeline — Rhino Terminal</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
:root{--bg:#0d0f14;--surf:#141820;--card:#181c26;--border:#1e2332;
|
||||||
|
--amber:#ffb000;--amber-bright:#ffd866;--amber-dim:#665500;--amber-faint:#2a2200;
|
||||||
|
--green:#4caf7d;--red:#ef5350;--cyan:#7ae9ff;--muted:#3a3e4a;
|
||||||
|
--font:'JetBrains Mono','Fira Code','Cascadia Code',monospace
|
||||||
|
}
|
||||||
|
body{font-family:var(--font);background:var(--bg);color:var(--amber);min-height:100vh;padding:16px;line-height:1.7;font-size:14px}
|
||||||
|
.container{max-width:720px;margin:0 auto}
|
||||||
|
body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;background:radial-gradient(ellipse at center,rgba(255,176,0,0.015) 0%,transparent 70%);z-index:999}
|
||||||
|
|
||||||
|
.term-bar{border:1px solid var(--amber-faint);border-radius:4px 4px 0 0;background:var(--surf);overflow:hidden;margin-bottom:8px}
|
||||||
|
.term-title{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;background:var(--card);border-bottom:1px solid var(--amber-faint);font-size:11px;color:var(--amber-dim);user-select:none}
|
||||||
|
.term-title .dots{display:flex;gap:4px}
|
||||||
|
.term-title .dots .dot{width:10px;height:10px;border-radius:50%;margin:0}
|
||||||
|
.dot.r{background:#ff5f56}.dot.y{background:#ffbd2e}.dot.g{background:#27c93f}
|
||||||
|
.term-title .name{font-size:10px;text-transform:uppercase;letter-spacing:0.8px}
|
||||||
|
.term-body{padding:18px 16px 14px;min-height:200px}
|
||||||
|
|
||||||
|
.prompt-line{display:flex;align-items:center;gap:2px;margin-bottom:2px;font-size:13px;color:var(--amber-dim)}
|
||||||
|
.prompt-line .ps1{color:var(--amber-bright);font-weight:500}
|
||||||
|
.prompt-line .ps2{color:var(--muted)}
|
||||||
|
|
||||||
|
.sect{margin-bottom:12px}
|
||||||
|
.sect-head{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;user-select:none;border:1px solid var(--border);background:var(--surf);transition:all 0.2s}
|
||||||
|
.sect-head:hover{border-color:var(--amber-dim)}
|
||||||
|
.sect-head .arrow{transition:transform 0.25s;font-size:10px;color:var(--amber-dim)}
|
||||||
|
.sect-head.open .arrow{transform:rotate(90deg)}
|
||||||
|
.sect-head .label{font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:0.6px;color:var(--amber-bright)}
|
||||||
|
.sect-head .badge{font-size:9px;padding:1px 6px;border-radius:3px;margin-left:auto;font-weight:500}
|
||||||
|
.badge-green{background:rgba(76,175,125,0.15);color:var(--green)}
|
||||||
|
.badge-amber{background:rgba(255,176,0,0.15);color:var(--amber)}
|
||||||
|
.badge-cyan{background:rgba(122,233,255,0.15);color:var(--cyan)}
|
||||||
|
.sect-body{display:none;padding:12px;background:var(--card);border:1px solid var(--border);border-top:none}
|
||||||
|
.sect-body.open{display:block}
|
||||||
|
|
||||||
|
.entry{display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--amber-faint)}
|
||||||
|
.entry:last-child{border:none}
|
||||||
|
.entry .date{color:var(--amber-dim);font-size:11px;white-space:nowrap;min-width:75px;padding-top:2px}
|
||||||
|
.entry .desc{color:var(--txt);font-size:12px;line-height:1.6}
|
||||||
|
.entry .desc .tag{display:inline-block;font-size:9px;padding:0 5px;border-radius:2px;margin-right:4px;font-weight:500}
|
||||||
|
.tag-add{background:rgba(76,175,125,0.2);color:var(--green)}
|
||||||
|
.tag-fix{background:rgba(239,83,80,0.2);color:var(--red)}
|
||||||
|
.tag-chore{background:rgba(122,233,255,0.15);color:var(--cyan)}
|
||||||
|
.tag-upgrade{background:rgba(255,176,0,0.15);color:var(--amber)}
|
||||||
|
|
||||||
|
.status-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}
|
||||||
|
.status-item{padding:8px 10px;background:var(--surf);border:1px solid var(--border);font-size:11px}
|
||||||
|
.status-item .key{color:var(--amber-dim)}
|
||||||
|
.status-item .val{color:var(--amber-bright);margin-top:2px}
|
||||||
|
.status-item .val.green{color:var(--green)}
|
||||||
|
.status-item .val.red{color:var(--red)}
|
||||||
|
|
||||||
|
.nav-links{display:flex;gap:12px;justify-content:center;padding:16px 0 8px}
|
||||||
|
.nav-links a{color:var(--amber-dim);text-decoration:none;font-size:11px;letter-spacing:0.4px}
|
||||||
|
.nav-links a:hover{color:var(--amber-bright)}
|
||||||
|
|
||||||
|
.rhino-ascii{text-align:center;padding:12px 0;color:var(--amber-dim);font-size:10px;line-height:1.3;opacity:0.6}
|
||||||
|
|
||||||
|
@media(max-width:600px){.status-grid{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><div class="container">
|
||||||
|
|
||||||
|
<!-- Terminal Bar -->
|
||||||
|
<div class="term-bar">
|
||||||
|
<div class="term-title">
|
||||||
|
<div class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></div>
|
||||||
|
<span class="name">ops-timeline · v2.0</span>
|
||||||
|
<span style="font-size:10px;color:var(--amber-dim)">~$ █</span>
|
||||||
|
</div>
|
||||||
|
<div class="term-body">
|
||||||
|
|
||||||
|
<div class="prompt-line"><span class="ps1">rhino@green-jacket</span><span class="ps2">:</span><span class="ps1">~</span><span class="ps2">$ cat /var/log/deployments</span></div>
|
||||||
|
<div style="height:8px"></div>
|
||||||
|
|
||||||
|
<!-- ======== Changelog ======== -->
|
||||||
|
<div class="sect">
|
||||||
|
<div class="sect-head open" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
|
||||||
|
<span class="arrow">▶</span><span class="label">Changelog</span><span class="badge badge-cyan">v2.0</span>
|
||||||
|
</div>
|
||||||
|
<div class="sect-body open" id="changelog">
|
||||||
|
<div class="entry"><span class="date">2026-07-20</span><div class="desc"><span class="tag tag-add">ADD</span> Ops Timeline page at <code>/ops</code></div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-20</span><div class="desc"><span class="tag tag-add">ADD</span> Market Intel API + dashboard tab with salary charts</div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-20</span><div class="desc"><span class="tag tag-add">ADD</span> Smart Match score column per job row</div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-20</span><div class="desc"><span class="tag tag-add">ADD</span> Evening Stand-Down briefing (6:45pm cron)</div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-20</span><div class="desc"><span class="tag tag-add">ADD</span> Backend backup to Gitea: Anthony/job-dashboard</div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-19</span><div class="desc"><span class="tag tag-add">ADD</span> Cover letter regeneration endpoint + docx export</div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-18</span><div class="desc"><span class="tag tag-add">ADD</span> Closing date urgency colours (past/urgent/warning)</div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-08</span><div class="desc"><span class="tag tag-chore">FIX</span> Stale job detection, bulk-edit, sorting</div></div>
|
||||||
|
<div class="entry"><span class="date">2026-07-03</span><div class="desc"><span class="tag tag-add">ADD</span> Initial job tracker dashboard — Notion sync + API</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== System Status ======== -->
|
||||||
|
<div class="sect">
|
||||||
|
<div class="sect-head open" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
|
||||||
|
<span class="arrow">▶</span><span class="label">System Status</span><span class="badge badge-green">online</span>
|
||||||
|
</div>
|
||||||
|
<div class="sect-body open">
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-item"><div class="key">Dashboard Server</div><div class="val green">● Running</div><div class="key" style="margin-top:4px">port 9099</div></div>
|
||||||
|
<div class="status-item"><div class="key">Notion Sync</div><div class="val green">● Auto-refresh 5min</div><div class="key" style="margin-top:4px">jobs_cache.json</div></div>
|
||||||
|
<div class="status-item"><div class="key">Evening Stand-Down</div><div class="val green">● Cron active</div><div class="key" style="margin-top:4px">weekdays 18:45 AWST</div></div>
|
||||||
|
<div class="status-item"><div class="key">Gitea Backup</div><div class="val green">● Pushed</div><div class="key" style="margin-top:4px">Anthony/job-dashboard</div></div>
|
||||||
|
<div class="status-item"><div class="key">Cover Letter Engine</div><div class="val green">● OmniRoute</div><div class="key" style="margin-top:4px">cover-letter-writer combo</div></div>
|
||||||
|
<div class="status-item"><div class="key">Market Intel</div><div class="val green">● Live</div><div class="key" style="margin-top:4px">/api/market-intel</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== Tech Stack ======== -->
|
||||||
|
<div class="sect">
|
||||||
|
<div class="sect-head" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
|
||||||
|
<span class="arrow">▶</span><span class="label">Tech Stack</span><span class="badge badge-cyan">built with</span>
|
||||||
|
</div>
|
||||||
|
<div class="sect-body">
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-item"><div class="key">Runtime</div><div class="val">Python 3 · HTTPServer</div></div>
|
||||||
|
<div class="status-item"><div class="key">Data</div><div class="val">Notion API · jobs_cache.json</div></div>
|
||||||
|
<div class="status-item"><div class="key">LLM</div><div class="val">OmniRoute · cover-letter-writer</div></div>
|
||||||
|
<div class="status-item"><div class="key">Infra</div><div class="val">Hermes CT460 · Tailscale</div></div>
|
||||||
|
<div class="status-item"><div class="key">Git</div><div class="val">Gitea · Anthony/job-dashboard</div></div>
|
||||||
|
<div class="status-item"><div class="key">CI</div><div class="val">Belongs to the rhino now</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== Rhino Footer ======== -->
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/api/jobs">API</a>
|
||||||
|
<a href="/evening.html">Evening Briefing</a>
|
||||||
|
<a href="https://gitea.kangaroo-eel.ts.net/Anthony/job-dashboard" target="_blank">Source</a>
|
||||||
|
</div>
|
||||||
|
<div class="rhino-ascii">
|
||||||
|
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓<br>
|
||||||
|
┃ The Rhino in the Green Jacket ┃<br>
|
||||||
|
┃ "Keep the weird underneath the ┃<br>
|
||||||
|
┃ competent." ┃<br>
|
||||||
|
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user