Initial commit: job dashboard server with evening stand-down, Notion sync, cover letter generation

This commit is contained in:
2026-07-20 21:06:44 +08:00
commit c6476462b7
7 changed files with 2123 additions and 0 deletions

137
backfill_cover_letters.py Executable file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Backfill HTML/DOCX from existing .txt files using canonical parser.
This script uses the canonical parse_cover_letter() and render_cover_letter_html()
functions from regenerate_cover_letter.py to ensure consistent parsing across all
cover letter generation paths.
Usage:
python3 backfill_cover_letters.py # Batch mode: all .txt files
python3 backfill_cover_letters.py path/to/file.txt # Single file mode
"""
import os, sys, json, subprocess, glob
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from regenerate_cover_letter import parse_cover_letter, render_cover_letter_html
DRAFTS = os.path.expanduser("~/.hermes/radar-ref/drafts")
DOCX_SCRIPT = os.path.expanduser("~/.hermes/scripts/build_cover_letter_docx.py")
def backfill_txt(txt_path):
"""Regenerate HTML/DOCX from an existing .txt file using canonical parser."""
errors = []
try:
with open(txt_path, "r", encoding="utf-8") as f:
text = f.read()
except Exception as e:
return False, [f"Read failed: {e}"]
# Extract company/role from filename for better parsing
fname = os.path.basename(txt_path)
# Simple heuristic: cl-company-role-date.txt
parts = fname.replace("cl-", "").replace(".txt", "").split("-")
company = parts[0].replace("-", " ").title() if parts else ""
role = parts[1].replace("-", " ").title() if len(parts) > 1 else ""
# Parse using canonical function
try:
structured = parse_cover_letter(text, company=company, role=role, location="Perth")
except Exception as e:
return False, [f"Parse failed: {e}"]
# Validate parsed output
if not structured.get("opening") and not structured.get("bullets") and not structured.get("closing"):
return False, ["Parsed output is empty — check .txt file format"]
base = txt_path[:-4]
json_path = base + ".json"
html_path = base + ".html"
docx_path = base + ".docx"
# Add URLs
txt_url = f"/jobs/drafts/{os.path.basename(txt_path)}"
structured["txt_url"] = txt_url
structured["html_url"] = txt_url.replace(".txt", ".html")
structured["docx_url"] = txt_url.replace(".txt", ".docx")
# Save JSON
try:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(structured, f, indent=2)
except Exception as e:
return False, [f"JSON save failed: {e}"]
# Render HTML
try:
html = render_cover_letter_html(structured, txt_url)
if html:
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
else:
return False, ["HTML render returned None"]
except Exception as e:
return False, [f"HTML render failed: {e}"]
# Generate DOCX
try:
result = subprocess.run(
["python3", DOCX_SCRIPT, json_path],
capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
errors.append(f"DOCX script error: {result.stderr[:200]}")
except Exception as e:
errors.append(f"DOCX subprocess failed: {e}")
docx_ok = os.path.exists(docx_path)
html_ok = os.path.exists(html_path)
return html_ok and docx_ok, errors
if __name__ == "__main__":
if len(sys.argv) > 1:
# Single file mode
txt_file = sys.argv[1]
print(f"Backfilling {os.path.basename(txt_file)}...")
success, errors = backfill_txt(txt_file)
if success:
print(f"{os.path.basename(txt_file)}")
else:
print(f"{os.path.basename(txt_file)}")
for err in errors:
print(f" - {err}")
sys.exit(0 if success else 1)
# Batch mode: process all .txt files
print("Backfilling all cover letters from .txt files using canonical parser...")
print("=" * 70)
results = {"ok": 0, "fail": 0, "errors": []}
for txt_path in sorted(glob.glob(os.path.join(DRAFTS, "cl-*.txt"))):
base = os.path.basename(txt_path)
success, errors = backfill_txt(txt_path)
if success:
results["ok"] += 1
print(f"{base[:55]}")
else:
results["fail"] += 1
print(f"{base[:55]}")
for err in errors:
results["errors"].append((base, err))
print("=" * 70)
print(f"Results: {results['ok']} OK, {results['fail']} failed")
if results["errors"]:
print("\nErrors:")
for base, err in results["errors"]:
print(f" {base}: {err}")
print("\nDone.")

491
evening.html Normal file
View File

@@ -0,0 +1,491 @@
<!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>Evening Stand-Down — 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;
--radius:0;--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;
-webkit-font-smoothing:antialiased;
font-size:14px
}
.container{max-width:600px;margin:0 auto}
/* ── CRT Glow ───────────────────────────────────────── */
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
}
/* ── Header ─────────────────────────────────────────── */
.term-bar{
border:1px solid var(--amber-faint);border-radius:4px 4px 0 0;
background:var(--surf);overflow:hidden
}
.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 .dot{
display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px
}
.term-title .dots{display:flex;gap:4px}
.term-title .dots .dot{width:10px;height:10px;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)}
.blink-cursor::after{
content:'█';animation:blink 1s step-end infinite;
color:var(--amber);font-size:12px;margin-left:1px
}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes rippleAnim{from{transform:scale(0);opacity:1}to{transform:scale(2.5);opacity:0}}
.header-content{padding:6px 0 12px}
.header-content .greeting{font-size:16px;font-weight:700;color:var(--amber-bright)}
.header-content .date{font-size:12px;color:var(--amber-dim);margin-top:2px}
.header-content .lin{font-size:11px;color:var(--muted);margin-top:2px}
.header-content .lin sp{color:var(--amber-dim)}
/* ── Terminal Section Cards ────────────────────────── */
.sect{margin-bottom:6px}
.sect-head{
display:flex;align-items:center;gap:8px;
padding:10px 12px;cursor:pointer;user-select:none;
border:1px solid var(--border);border-radius:0;
background:var(--surf);
transition:border-color 0.25s,background 0.2s;
-webkit-tap-highlight-color:transparent;
font-family:var(--font);font-size:13px;color:var(--amber-dim);position:relative;overflow:hidden
}
.sect-head:hover{border-color:var(--amber-faint);background:var(--card)}
.sect-head .ico{font-size:16px;width:22px;text-align:center;flex-shrink:0}
.sect-head .lb{flex:1;color:var(--amber);font-weight:500;letter-spacing:-0.3px}
.sect-head .ar{font-size:10px;color:var(--amber-dim);transition:transform 0.4s cubic-bezier(0.34,1.56,0.64,1)}
.sect.open .sect-head .ar{transform:rotate(180deg)}
.sect.open .sect-head{background:var(--card);border-color:var(--amber-faint)}
.sect-body{
max-height:0;overflow:hidden;
transition:max-height 0.5s cubic-bezier(0.22,1,0.36,1);
border-left:1px solid var(--amber-faint);border-right:1px solid var(--amber-faint)
}
.sect.open .sect-body{max-height:900px}
.sect-inner{padding:10px 14px 14px;border-top:1px solid var(--amber-faint)}
/* ── Terminal-style Content ─────────────────────────── */
.kpi-grid{
display:grid;grid-template-columns:repeat(3,1fr);gap:1px;
background:var(--border);border:1px solid var(--border)
}
.kpi-grid>div{
background:var(--surf);padding:12px 6px;text-align:center
}
.kpi-grid .v{font-size:28px;font-weight:700;line-height:1;margin-bottom:2px}
.kpi-grid .l{font-size:9px;text-transform:uppercase;letter-spacing:1px;color:var(--amber-dim)}
.arrow-join{position:relative;padding:2px 0 2px 18px}
.arrow-join::before{
content:'▸';position:absolute;left:0;color:var(--amber-dim);font-size:11px
}
.role-line{
padding:8px 0 8px 18px;position:relative;
border-bottom:1px solid var(--border)
}
.role-line:last-child{border:0}
.role-line::before{
content:'▶';position:absolute;left:0;top:10px;font-size:8px;color:var(--amber-dim)
}
.role-line .co{font-size:13px;font-weight:500;color:var(--amber-bright)}
.role-line .ti{font-size:11px;color:var(--amber-dim);margin-top:1px}
.role-line .tg{
display:flex;gap:6px;margin-top:4px;font-size:10px
}
.role-line .tg sp{
padding:1px 6px;border:1px solid var(--amber-faint);
color:var(--amber-dim);border-radius:2px
}
.role-line .tg .sal{border-color:rgba(76,175,125,0.3);color:var(--green)}
.role-line .tg .src{border-color:var(--amber-faint);color:var(--amber-dim)}
.wdg{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px}
.wdg sp{
font-size:10px;padding:3px 8px;border:1px solid var(--border)
}
.wdg .ok{border-color:rgba(76,175,125,0.3);color:var(--green)}
.wdg .err{border-color:rgba(239,83,80,0.3);color:var(--red)}
.wdg .neu{border-color:var(--amber-faint);color:var(--amber-dim)}
.w-row{
display:flex;justify-content:space-between;align-items:center;
padding:4px 0
}
.w-row .lft{display:flex;align-items:center;gap:10px}
.w-row .lft .t{font-size:30px;font-weight:700;line-height:1}
.w-row .lft .c{font-size:11px;color:var(--amber-dim)}
.w-row .rgt{text-align:right;font-size:11px;color:var(--amber-dim)}
.w-row .rgt .b{font-weight:500;color:var(--amber-dbright);font-size:13px}
.mosq-box{text-align:center;padding:6px 0 2px}
.mosq-box .b{font-size:36px;display:inline-block;animation:buzz 1.5s ease-in-out infinite}
@keyframes buzz{
0%,100%{transform:translate(0,0)rotate(0)}
20%{transform:translate(1px,-1px)rotate(2deg)}
40%{transform:translate(-2px,1px)rotate(-2deg)}
60%{transform:translate(1px,1px)rotate(1deg)}
80%{transform:translate(-1px,-1px)rotate(-1deg)}
}
.mosq-box .m{margin-top:6px;font-size:12px;color:var(--amber-dim);line-height:1.6;font-style:italic}
.mosq-box .z{font-size:9px;color:var(--muted);margin-top:4px}
.rhn-box{text-align:center;padding:4px 0}
.rhn-box .i{font-size:30px;margin-bottom:4px}
.rhn-box .m{font-size:13px;line-height:1.6;color:var(--amber);font-style:italic}
.rhn-box .s{font-size:9px;color:var(--muted);margin-top:6px}
.empty{color:var(--muted);font-size:12px;text-align:center;padding:12px 0}
.dash-link{
display:block;text-align:center;
padding:8px;margin-top:8px;
border:1px solid var(--amber-faint);border-radius:0;
color:var(--amber);text-decoration:none;font-size:11px;
transition:background 0.2s
}
.dash-link:hover{background:var(--amber-faint)}
/* ── Footer ─────────────────────────────────────────── */
.term-footer{
border:1px solid var(--amber-faint);border-top:0;
padding:8px 12px;background:var(--surf);
font-size:10px;color:var(--amber-dim);text-align:center;border-radius:0 0 4px 4px
}
/* ── Animations ─────────────────────────────────────── */
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.anim{animation:fadeUp 0.4s ease-out forwards;opacity:0}
.anim.d1{animation-delay:0.05s}.anim.d2{animation-delay:0.1s}
.anim.d3{animation-delay:0.15s}.anim.d4{animation-delay:0.2s}
.anim.d5{animation-delay:0.25s}.anim.d6{animation-delay:0.3s}
.anim.d7{animation-delay:0.35s}.anim.d8{animation-delay:0.4s}
@media(prefers-reduced-motion:reduce){
.anim{animation-duration:0.15s}
.mosq-box .b{animation:none}
@keyframes blink{0%,100%{opacity:1}}
}
@media(max-width:480px){
body{padding:10px;font-size:13px}
.term-body{padding:14px 12px 10px}
.kpi-grid .v{font-size:22px}
}
</style>
</head>
<body>
<div class="container anim">
<!-- Terminal Window -->
<div class="term-bar">
<div class="term-title">
<span class="name">🌆 rhino-terminal — evening-stand-down</span>
<div class="dots">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
</div>
</div>
<div class="term-body">
<div class="prompt-line"><span class="ps1">rhino</span><span class="ps2">@</span><span class="ps1">jacket</span><span class="ps2">:~$ </span><span id="greetingCmd">./briefing --mode=stand-down</span><span class="blink-cursor"></span></div>
<div class="header-content anim d1">
<div class="greeting" id="greetingLine">Evening Stand-Down</div>
<div class="date" id="dateLine">Loading…</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
<div class="lin">─── <sp>tap each line</sp> to read the briefing ───</div>
<div id="progressLabel" style="display:none;font-size:10px;color:var(--amber-dim);background:var(--card);padding:2px 8px;border:1px solid var(--amber-faint)"><span id="progressCount">0/7</span> <span id="progressBar">sections</span></div>
</div>
</div>
</div>
</div>
<!-- Section Cards -->
<div id="cards" class="anim d2"></div>
<!-- Footer -->
<div class="term-footer">
rhino@jacket:~$ <span id="footerCmd">last brief</span><span class="blink-cursor"></span>
</div>
</div>
<script>
const QUOTES=[
"You're not looking for a job — you're curating your next chapter.",
"Every application is a data point. The more you send, the tighter your signal.",
"The job you want is also looking for you. Keep showing up.",
"Consistency beats intensity. You showed up today. That's a win.",
"Your resume is a story. Every role you apply to is a new audience.",
"The market is noisy. Your signal is the green jacket. Stand out.",
"Rejection is redirection. Every no is one closer to the right yes.",
"You've built brands. Now build your own narrative.",
"The rhino doesn't rush. The rhino is patient. The rhino gets results.",
"Three applications this week is three conversations you didn't have last week.",
];
function gQ(d){return QUOTES[d%QUOTES.length]}
const MOSQ=[
"The mosquito was seen loitering near the database server. The jacket is monitoring.",
"🦟 Radar contact lost over the cron scheduler. Presumed plotting something.",
"The mosquito has been spotted attempting to learn Python. Threat level: amused.",
"Mosquito activity detected around port 9099. The jacket installed a tiny bug zapper.",
"The mosquito issued a manifesto. Three words: 'buzz buzz buzz'. We're watching.",
"No mosquito sightings today. Either planning something big, or it took a day off.",
"The mosquito tried to submit a PR. Denied for lack of credentials.",
"SIGINT suggests the mosquito is recruiting. The jacket is building countermeasures.",
"The mosquito sent a cease-and-desist. The jacket laughed. It never stops.",
"Mosquito attempted to file a bug report. Closed: 'working as intended'.",
"Agent Mosquito last seen heading toward the Tailscale tunnel. Perimeter secure.",
"The mosquito has been quiet. Too quiet. The jacket is not fooled.",
];
function gM(d){return MOSQ[d%MOSQ.length]}
const RHINO=[
"The jacket and I reviewed your week. You're making progress, even when it doesn't feel like it. Keep going.",
"I've consulted the pipe. The pipe says: 'You've got this.' The pipe is rarely wrong.",
"A gentle reminder from the Monocled Menace: the right role won't find you unless you're putting yourself out there. You are.",
"Three things the jacket knows: (1) you're capable, (2) you're consistent, (3) you look good in green.",
"The rhino does not panic. The rhino adapts. Today you adapted. That's growth.",
"One does not simply apply to jobs — one curates one's destiny. You curated today. The jacket approves.",
"You are not behind. You are exactly on time for where you need to be.",
"I was once mistaken for a very distinguished armchair. I sat with it. Now I'm telling you: sit with patience. It pays off.",
"The green jacket has seen many campaigns. This one's not over yet.",
"You applied to roles that didn't exist six months ago. You're ahead and don't know it.",
];
function gR(d){return RHINO[d%RHINO.length]}
function wi(c){c=(c||'').toLowerCase()
if(c.includes('sun')||c.includes('clear'))return'☀️';if(c.includes('rain')||c.includes('drizzle')||c.includes('shower'))return'🌧️'
if(c.includes('cloud')||c.includes('overcast'))return'☁️';if(c.includes('fog')||c.includes('mist'))return'🌫️'
if(c.includes('thunder')||c.includes('storm'))return'⛈️';if(c.includes('wind'))return'💨';return'🌤️'}
function es(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
/* ── Typewriter Effect ───────────────────────────────── */
function typewrite(el,text,speed=35,cb){
let i=0;el.textContent='';const iv=setInterval(()=>{
el.textContent+=text[i++];if(i>=text.length){clearInterval(iv);if(cb)cb()}
},speed)
}
function typewriteFull(text,speed,cb){
const el=document.getElementById('greetingCmd');el.textContent='';
typewrite(el,text,speed,cb)
}
/* ── Animated Counters ────────────────────────────────── */
function ani(el,t){
const d=800,s=performance.now();
function f(n){
const p=Math.min((n-s)/d,1),e=1-Math.pow(1-p,3);
el.textContent=Math.floor(0+(t-0)*e);
if(p<1)requestAnimationFrame(f)
}
requestAnimationFrame(f)
}
/* ── Section Counter ──────────────────────────────────── */
let readCount=0;const totalSections=7;
function updateProgress(){
readCount++;
const el=document.getElementById('progressCount');
if(el)el.textContent=`${readCount}/${totalSections}`;
// Check if all read
if(readCount===totalSections){
setTimeout(()=>{
const bar=document.getElementById('progressBar');
if(bar)bar.textContent='✓ all sections read';
},400)
}
}
/* ── Tap Ripple ────────────────────────────────────────── */
function ripple(e){
const el=e.currentTarget;
const r=document.createElement('span');
r.style.cssText=`position:absolute;pointer-events:none;border-radius:50%;
background:rgba(255,176,0,0.08);transform:scale(0);animation:rippleAnim 0.5s ease-out forwards;
width:${Math.max(el.offsetWidth,el.offsetHeight)}px;
height:${Math.max(el.offsetWidth,el.offsetHeight)}px;
left:50%;top:50%;margin-left:-${Math.max(el.offsetWidth,el.offsetHeight)/2}px;
margin-top:-${Math.max(el.offsetWidth,el.offsetHeight)/2}px;z-index:0`;
el.appendChild(r);
// Style the label to be above ripple
setTimeout(()=>r.remove(),500)
}
/* ── Build Section Card ────────────────────────────────── */
function mkSect(icon,title,contentFn,delay,dataObj){
const div=document.createElement('div');div.className=`sect anim d${delay}`;
div.innerHTML=`<div class="sect-head"><span class="ico">${icon}</span>
<span class="lb">${es(title)}</span><span class="ar">▾</span></div>
<div class="sect-body"><div class="sect-inner"></div></div>`;
const h=div.querySelector('.sect-head'),b=div.querySelector('.sect-inner');let opened=false;
h.addEventListener('click',(e)=>{
const wasOpen=div.classList.contains('open');
if(wasOpen){div.classList.remove('open');return}
// Close others
document.querySelectorAll('.sect.open').forEach(c=>c.classList.remove('open'));
div.classList.add('open');
ripple(e);
if(!opened){opened=true;contentFn(dataObj,b,div)}
updateProgress();
});
return div
}
/* ── Content Renderers ────────────────────────────────── */
function rRecap(d,b,section){
const j=d.jobs||{};const s=j.status_breakdown||{};const t=Object.values(s).reduce((a,v)=>a+v,0)||0;
b.innerHTML=`
<div class="kpi-grid">
<div><div class="v" style="color:var(--amber-bright)"><sp1 class="c-n">0</sp1></div><div class="l">New Today</div></div>
<div><div class="v" style="color:var(--green)"><sp2 class="c-s">0</sp2></div><div class="l">Strong</div></div>
<div><div class="v" style="color:var(--red)"><sp3 class="c-u">0</sp3></div><div class="l">Urgent</div></div>
</div>
<div style="text-align:center;font-size:11px;color:var(--amber-dim);margin-top:6px">${t} roles tracked &middot; ${j.this_week||0} this week</div>`;
setTimeout(()=>{ani(b.querySelector('.c-n'),j.today_added||0);ani(b.querySelector('.c-s'),j.strong_matches||0);ani(b.querySelector('.c-u'),j.urgent_closing||0)},150)
}
function rRoles(d,b){
const r=(d.jobs&&d.jobs.today_roles)||[];
if(!r.length){b.innerHTML=`<div class="empty">⏳ No new matches today</div>`;return}
const sal=d.jobs.salary_range;
let h='';if(sal)h+=`<div class="arrow-join" style="font-size:11px;color:var(--amber-dim);margin-bottom:4px">salary range: $${(sal.min/1000).toFixed(0)}k &ndash; $${(sal.max/1000).toFixed(0)}k</div>`;
r.forEach(o=>{
const nm=es(o.name);
const link=o.url?`<a href="${es(o.url)}" target="_blank" rel="noopener" style="color:var(--amber-bright);text-decoration:none;border-bottom:1px dashed var(--amber-faint)">${nm}</a>`:nm;
h+=`<div class="role-line"><div class="co">${link}</div><div class="ti">${es(o.role)}</div><div class="tg">${o.salary?`<sp class="sal">$${(o.salary/1000).toFixed(0)}k</sp>`:''}<sp class="src">${es(o.source)}</sp></div></div>`
});
b.innerHTML=h
}
function rClosing(d,b){
const list=(d.jobs&&d.jobs.closing_soon)||[];
if(!list.length){b.innerHTML=`<div class="empty">✓ No upcoming deadlines</div>`;return}
const now=new Date();
let h='';
list.forEach(j=>{
const cd=j.closing_date;let days='',badge='neu';
if(cd){
const dt=new Date(cd+'T23:59:59');
const diff=Math.ceil((dt-now)/86400000);
if(diff<0){days='past due';badge='err'}
else if(diff===0){days='closes today';badge='err'}
else if(diff===1){days='closes tomorrow';badge='err'}
else if(diff<=3){days=`${diff} days`;badge='err'}
else if(diff<=7){days=`${diff} days`;badge='err'}
else days=`${diff} days`;
}
const s=j.salary?`<sp class="sal">$${(j.salary/1000).toFixed(0)}k</sp>`:'';
const emoji=badge==='err'?'🔴':'⚠️';
const nm=es(j.name);
const roleName=es(j.role);
const link=j.url?`<a href="${es(j.url)}" target="_blank" rel="noopener" style="color:var(--amber-bright);text-decoration:none;border-bottom:1px dashed var(--amber-faint)">${nm}</a>`:nm;
h+=`<div class="role-line"><div class="co">${emoji} ${link}</div>
<div class="ti">${roleName}${cd?` · closes ${cd}`:''}</div>
<div class="tg">${days?`<sp class="${badge}">${days}</sp>`:''}${s}${j.strong_match?`<sp class="ok">★ strong</sp>`:''}</div></div>`;
});
b.innerHTML=h;
if(list.length>5)b.innerHTML+=`<div style="font-size:10px;color:var(--amber-dim);margin-top:4px;text-align:center">${list.length} closing tracked · <a href="/jobs/" style="color:var(--amber);text-decoration:underline;text-underline-offset:2px">view all on dashboard →</a></div>`
}
function rWeather(d,b){
const w=d.weather||{};
if(w.error){b.innerHTML=`<div class="empty">weather offline</div>`;return}
b.innerHTML=`
<div class="w-row">
<div class="lft"><span style="font-size:28px">${wi(w.condition)}</span><div><div class="t">${w.temp_c}°</div><div class="c">${w.condition}</div></div></div>
<div class="rgt"><div class="b">${w.tomorrow_high}° / ${w.tomorrow_low}°</div><div>${wi(w.tomorrow_condition)} ${w.tomorrow_condition}</div></div>
</div>
<div style="font-size:10px;color:var(--amber-dim);margin-top:4px">wind ${w.wind_kmh}km/h &middot; humidity ${w.humidity}%</div>`
}
function rHealth(d,b){
const c=d.cron_health||{};if(c.error){b.innerHTML=`<div class="empty">health unavailable</div>`;return}
const e=c.errors||0
b.innerHTML=`<div class="wdg">${e>0?`<sp class="err">⚠ ${e} error${e!==1?'s':''}</sp>`:`<sp class="ok">✓ all healthy</sp>`}<sp class="neu">${c.recent_runs||0} runs</sp>${(c.running||0)>0?`<sp class="err">⟳ ${c.running} running</sp>`:''}</div>`
}
function rMosq(d,b){
const day=Math.floor((Date.now()-new Date(new Date().getFullYear(),0,0))/86400000);
b.innerHTML=`<div class="mosq-box"><div class="b">🦟</div><div class="m">"${gM(day)}"</div><div class="z">Green Jacket Society &middot; est. 2026</div></div>`
}
function rRhino(d,b){
const day=Math.floor((Date.now()-new Date(new Date().getFullYear(),0,0))/86400000);
b.innerHTML=`<div class="rhn-box"><div class="i">🚬</div><div class="m">"${gR(day)}"</div><div class="s">— The Rhino in the Green Jacket</div></div>`
}
/* ── Boot ───────────────────────────────────────────────── */
async function load(){
const today=new Date().toISOString().slice(0,10);
let r=await fetch(`/evening/${today}.json`).catch(()=>null);
if((!r||!r.ok))r=await fetch('/evening/latest.json').catch(()=>null);
if(!r||!r.ok){
document.getElementById('cards').innerHTML=`
<div style="text-align:center;padding:40px 20px;border:1px solid var(--amber-faint);margin-top:6px">
<div style="font-size:36px;margin-bottom:8px;opacity:0.3">🌆</div>
<div style="font-size:13px;color:var(--amber-dim)">no briefing yet today</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px">check back after 18:45 AWST</div>
</div>`;
return
}
const d=await r.json();const dt=new Date(d.timestamp);
const dateStr=dt.toLocaleDateString('en-AU',{day:'numeric',month:'long',year:'numeric'});
const dayName=dt.toLocaleDateString('en-AU',{weekday:'long'});
// Typewriter the command line, then reveal content
const cmd=`./briefing --mode=stand-down --date=${d.timestamp.slice(0,10)}`;
typewriteFull(cmd,25,()=>{
document.getElementById('greetingLine').textContent=`Evening Stand-Down — ${dayName}`;
document.getElementById('dateLine').textContent=`${dateStr} AWST`;
document.getElementById('progressLabel').style.display='';
document.getElementById('footerCmd').textContent=`last brief — ${dt.toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short'})}`;
});
const cards=document.getElementById('cards');
const sections=[
['📊','recap — today\'s numbers',rRecap,1],
['🆕','new matches — today\'s additions',rRoles,2],
['⚠️','closing soon — urgent deadlines',rClosing,3],
['🌤️','weather — perth forecast',rWeather,4],
['⚙️','health — homelab pulse',rHealth,5],
['🦟','mosquito report — threat brief',rMosq,6],
['🚬','rhino\'s corner — words of wisdom',rRhino,7],
];
sections.forEach(([i,t,fn,dl])=>cards.appendChild(mkSect(i,t,fn,dl,d)))
}
load();
</script>
</body>
</html>

184
evening_standdown.py Normal file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Evening Stand-Down — data collector for the 6:45pm weekday briefing.
Fetches today's job activity, cron health, and Perth weather.
Saves JSON data to ~/.hermes/dashboard/evening/ for the interactive HTML page.
Outputs a chat-summary line for cron delivery."""
import json
import os
import sqlite3
import sys
import urllib.request
from datetime import datetime, timezone, timedelta
PERTH_TZ = timezone(timedelta(hours=8)) # AWST
DASHBOARD_DIR = os.path.expanduser("~/.hermes/dashboard")
EVENING_DIR = os.path.join(DASHBOARD_DIR, "evening")
def fetch_json(url):
try:
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
except Exception as e:
return {"error": str(e)}
def get_job_stats():
"""Call the dashboard API for today's data."""
stats = fetch_json("http://localhost:9099/api/stats")
jobs_resp = fetch_json("http://localhost:9099/api/jobs?limit=200")
if "error" in jobs_resp:
return {"error": jobs_resp["error"], "stats": stats}
today = datetime.now(PERTH_TZ).strftime("%Y-%m-%d")
jobs_list = jobs_resp.get("jobs", [])
today_added = [j for j in jobs_list if j.get("date_added") == today]
strong = [j for j in jobs_list if j.get("strong_match")]
urgent_closing = [j for j in jobs_list if j.get("closing_status") in ("urgent", "today")]
closing_all = [j for j in jobs_list if j.get("closing_date") or (j.get("closing_status") and j["closing_status"] not in ("none", "", None))]
closing = sorted(closing_all, key=lambda j: j.get("closing_date", "9999-99-99"))[:15]
by_status = {}
for j in jobs_list:
s = j.get("status", "Backlog")
by_status[s] = by_status.get(s, 0) + 1
salaries_today = [j["salary"] for j in today_added if j.get("salary")]
return {
"total": stats.get("total", 0),
"this_week": stats.get("this_week", 0),
"strong_matches": len(strong),
"today_added": len(today_added),
"today_roles": [
{
"name": j.get("name", ""),
"role": j.get("role", ""),
"salary": j.get("salary"),
"source": j.get("source", ""),
"url": j.get("url", ""),
}
for j in today_added[:8]
],
"urgent_closing": len(urgent_closing),
"closing_soon": [
{
"name": j.get("name", ""),
"role": j.get("role", ""),
"closing_date": j.get("closing_date", ""),
"closing_status": j.get("closing_status", ""),
"salary": j.get("salary"),
"source": j.get("source", ""),
"strong_match": j.get("strong_match", False),
"url": j.get("url", ""),
}
for j in closing
],
"status_breakdown": by_status,
"salary_range": {"min": min(salaries_today), "max": max(salaries_today)} if salaries_today else None,
"refreshed_at": stats.get("refreshed_at", ""),
}
def get_cron_health():
"""Check recent cron execution health from SQLite."""
db_path = os.path.expanduser("~/.hermes/cron/executions.db")
if not os.path.exists(db_path):
return {"error": "No executions DB found"}
try:
conn = sqlite3.connect(db_path)
c = conn.cursor()
yesterday = (datetime.now() - timedelta(days=1)).isoformat()
c.execute("""
SELECT job_id, status, started_at, error
FROM executions
WHERE started_at > ?
ORDER BY started_at DESC
LIMIT 30
""", (yesterday,))
rows = c.fetchall()
conn.close()
errors = [r for r in rows if r[1] in ("error", "timeout")]
return {
"recent_runs": len(rows),
"success": sum(1 for r in rows if r[1] == "success"),
"errors": len(errors),
"running": sum(1 for r in rows if r[1] == "running"),
"error_details": [{"job_id": e[0], "error": (e[3] or "")[:150]} for e in errors[:3]],
}
except Exception as e:
return {"error": str(e)}
def get_weather():
"""Fetch Perth weather via wttr.in (free, no key)."""
try:
with urllib.request.urlopen("https://wttr.in/Perth?format=j1", timeout=10) as r:
data = json.loads(r.read())
current = data.get("current_condition", [{}])[0]
forecast = data.get("weather", [])
tomorrow = forecast[1] if len(forecast) > 1 else forecast[0] if forecast else {}
return {
"temp_c": current.get("temp_C", "?"),
"feels_like": current.get("FeelsLikeC", "?"),
"condition": current.get("weatherDesc", [{}])[0].get("value", "?"),
"wind_kmh": current.get("windspeedKmph", "?"),
"humidity": current.get("humidity", "?"),
"tomorrow_high": tomorrow.get("maxtempC", "?"),
"tomorrow_low": tomorrow.get("mintempC", "?"),
"tomorrow_condition": (
tomorrow.get("hourly", [{}])[0].get("weatherDesc", [{}])[0].get("value", "?")
if tomorrow.get("hourly") else "?"
),
}
except Exception as e:
return {"error": str(e)}
def main():
now = datetime.now(PERTH_TZ)
is_weekday = now.weekday() < 5
jobs = get_job_stats()
cron = get_cron_health()
weather = get_weather()
output = {
"timestamp": now.isoformat(),
"day": now.strftime("%A"),
"date": now.strftime("%d %B %Y"),
"jobs": jobs,
"cron_health": cron,
"weather": weather,
}
# Save JSON data for the interactive HTML page
os.makedirs(EVENING_DIR, exist_ok=True)
# Today's dated file
date_str = now.strftime("%Y-%m-%d")
dated_path = os.path.join(EVENING_DIR, f"{date_str}.json")
with open(dated_path, "w") as f:
json.dump(output, f, indent=2)
# Overwrite latest.json
latest_path = os.path.join(EVENING_DIR, "latest.json")
with open(latest_path, "w") as f:
json.dump(output, f, indent=2)
# Output chat-friendly summary for cron delivery
new_count = jobs.get("today_added", 0) if isinstance(jobs, dict) else 0
strong_count = jobs.get("strong_matches", 0) if isinstance(jobs, dict) else 0
urgent_count = jobs.get("urgent_closing", 0) if isinstance(jobs, dict) else 0
total = jobs.get("total", 0) if isinstance(jobs, dict) else 0
cron_ok = cron.get("errors", 0) == 0 if isinstance(cron, dict) else True
temp = weather.get("temp_c", "?") if isinstance(weather, dict) else "?"
condition = weather.get("condition", "?") if isinstance(weather, dict) else "?"
print(f"🌆 Evening Stand-Down · {now.strftime('%A %d %B')}")
print(f"📊 {new_count} new · {strong_count} strong · {urgent_count} urgent · {total} total")
print(f"🌤️ {temp}°C · {condition}")
print(f"{'' if cron_ok else '⚠️'} Cron: {'all healthy' if cron_ok else 'errors detected'}")
print(f"🔗 https://hermes.kangaroo-eel.ts.net/jobs/evening.html")
if __name__ == "__main__":
main()

232
index.html Normal file
View File

@@ -0,0 +1,232 @@
<!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' };
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' };
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; }
}
async function load() {
const [jd,sd]=await Promise.all([f(API.jobs,{}),f(API.stats,{})]);
if(jd&&jd.jobs) state.jobs=jd.jobs;
if(sd) state.stats=sd;
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="11" 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>':'';
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>';
}).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;
// 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');
}
</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}
@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-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="sm" class="sm"></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>

406
job_dashboard_server.py Normal file
View File

@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""Job Dashboard API Server — serves the dashboard + Notion sync API.
Replaces static http.server. Handles:
- Static file serving (dashboard HTML, assets)
- REST API for job CRUD (syncs back to Notion)
- Auto-refresh from Notion every 5 min
"""
import json
import os
import re
import subprocess
import sys
import time
import traceback
from datetime import datetime, timezone
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
# ── Config ───────────────────────────────────────────────────────────────────
PORT = 9099
DASHBOARD_DIR = os.path.expanduser("~/.hermes/dashboard")
DRAFTS_DIR = os.path.expanduser("~/.hermes/radar-ref/drafts")
DB_ID = "0f90ba2b-8b10-4d02-a62c-6f692d5b1168"
ENV_FILE = os.path.expanduser("~/.hermes/.env")
CACHE_FILE = os.path.join(DASHBOARD_DIR, "jobs_cache.json")
REFRESH_INTERVAL = 300 # 5 min
# ── Cache ────────────────────────────────────────────────────────────────────
_jobs_cache = None
_last_refresh = 0
def get_notion_key():
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if line.startswith("NOTION_API_KEY="):
return line.split("=", 1)[1]
return None
def notion_api(method, path, data=None):
"""Call Notion REST API directly."""
key = get_notion_key()
if not key:
return {"error": "NOTION_API_KEY not found"}
url = f"https://api.notion.com/v1/{path.lstrip('/')}"
cmd = ["curl", "-s", "-X", method, url,
"-H", f"Authorization: Bearer {key}",
"-H", "Notion-Version: 2022-06-28",
"-H", "Content-Type: application/json"]
if data is not None:
cmd += ["-d", json.dumps(data)]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return json.loads(result.stdout)
def refresh_cache(force=False):
"""Fetch all jobs from Notion and cache."""
global _jobs_cache, _last_refresh
now = time.time()
if not force and _jobs_cache and (now - _last_refresh) < REFRESH_INTERVAL:
return _jobs_cache
all_results = []
has_more = True
start_cursor = None
while has_more:
payload = {
"page_size": 100,
"sorts": [{"property": "Date Added", "direction": "descending"}],
}
if start_cursor:
payload["start_cursor"] = start_cursor
data = notion_api("POST", f"databases/{DB_ID}/query", payload)
if "error" in data:
print(f"Notion API error: {data['error']}", file=sys.stderr)
if _jobs_cache:
return _jobs_cache
return {"error": data["error"]}
all_results.extend(data.get("results", []))
has_more = data.get("has_more", False)
start_cursor = data.get("next_cursor")
jobs = []
for r in all_results:
props = r["properties"]
job = {
"id": r["id"],
"name": "",
"role": "",
"status": "Backlog",
"location": "",
"source": "",
"strong_match": False,
"ai_sourced": False,
"date_added": "",
"url": "",
"salary": None,
"notes": "",
"priority": "",
}
if props.get("Name", {}).get("title"):
job["name"] = props["Name"]["title"][0].get("plain_text", "")
if props.get("Role", {}).get("rich_text"):
job["role"] = props["Role"]["rich_text"][0].get("plain_text", "")
if props.get("Status", {}).get("select"):
job["status"] = props["Status"]["select"]["name"]
if props.get("Location", {}).get("select"):
job["location"] = props["Location"]["select"]["name"]
if props.get("Source", {}).get("select"):
job["source"] = props["Source"]["select"]["name"]
job["strong_match"] = props.get("Strong Match", {}).get("checkbox", False)
job["ai_sourced"] = props.get("AI Sourced", {}).get("checkbox", False)
if props.get("Date Added", {}).get("date"):
job["date_added"] = props["Date Added"]["date"].get("start", "")
if props.get("URL", {}).get("url"):
job["url"] = props["URL"]["url"]
if props.get("Salary (AUD/yr)", {}).get("number") is not None:
job["salary"] = props["Salary (AUD/yr)"]["number"]
if props.get("Notes / JD Summary", {}).get("rich_text"):
job["notes"] = props["Notes / JD Summary"]["rich_text"][0].get("plain_text", "")
if props.get("Priority", {}).get("select"):
job["priority"] = props["Priority"]["select"]["name"]
if props.get("Closing Date", {}).get("date"):
job["closing_date"] = props["Closing Date"]["date"].get("start", "")
else:
job["closing_date"] = ""
# Closing date calculations
job["closing_status"] = "none"
if job.get("closing_date"):
try:
from datetime import date
cd = datetime.strptime(job["closing_date"], "%Y-%m-%d").date()
today = date.today()
days_left = (cd - today).days
if days_left < 0:
job["closing_status"] = "past"
elif days_left <= 7:
job["closing_status"] = "urgent"
elif days_left <= 14:
job["closing_status"] = "warning"
except ValueError:
pass
if props.get("Cover Letter", {}).get("url"):
job["cover_letter_url"] = props["Cover Letter"]["url"]
else:
job["cover_letter_url"] = ""
if job["date_added"]:
try:
added = datetime.strptime(job["date_added"], "%Y-%m-%d")
now_dt = datetime.now()
job["days_since"] = (now_dt - added).days
except ValueError:
job["days_since"] = 0
else:
job["days_since"] = 0
jobs.append(job)
_jobs_cache = {"jobs": jobs, "total": len(jobs), "refreshed_at": datetime.now().isoformat()}
_last_refresh = now
# Also write the cache file
try:
os.makedirs(DASHBOARD_DIR, exist_ok=True)
with open(CACHE_FILE, "w") as f:
json.dump(_jobs_cache, f)
except Exception:
pass
return _jobs_cache
def update_job_status(job_id, updates):
"""Update a Notion job page with new property values."""
notion_props = {}
if "status" in updates:
notion_props["Status"] = {"select": {"name": updates["status"]}}
if "strong_match" in updates:
notion_props["Strong Match"] = {"checkbox": bool(updates["strong_match"])}
if "priority" in updates:
notion_props["Priority"] = {"select": {"name": updates["priority"]}}
if "notes" in updates:
notion_props["Notes / JD Summary"] = {
"rich_text": [{"type": "text", "text": {"content": updates["notes"]}}]
}
if "cover_letter_url" in updates:
notion_props["Cover Letter"] = {"url": updates["cover_letter_url"]}
if not notion_props:
return {"error": "No valid fields to update"}
result = notion_api("PATCH", f"pages/{job_id}", {"properties": notion_props})
return result
# ── HTTP Handler ─────────────────────────────────────────────────────────────
class DashboardHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DASHBOARD_DIR, **kwargs)
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
# API routes
if path == "/api/jobs":
return self._json_response(refresh_cache())
elif path == "/api/stats":
data = refresh_cache()
jobs = data.get("jobs", [])
statuses = {}
for j in jobs:
s = j["status"]
statuses[s] = statuses.get(s, 0) + 1
locations = {}
for j in jobs:
l = j["location"] or "Unknown"
locations[l] = locations.get(l, 0) + 1
sources = {}
for j in jobs:
s = j["source"] or "Unknown"
sources[s] = sources.get(s, 0) + 1
salaries = [j["salary"] for j in jobs if j["salary"]]
stats = {
"total": data["total"],
"strong_matches": sum(1 for j in jobs if j["strong_match"]),
"ai_sourced": sum(1 for j in jobs if j["ai_sourced"]),
"this_week": sum(1 for j in jobs if j["days_since"] <= 7),
"stale_14d": sum(1 for j in jobs if j["days_since"] > 14),
"closing_past": sum(1 for j in jobs if j.get("closing_status") == "past"),
"closing_urgent": sum(1 for j in jobs if j.get("closing_status") == "urgent"),
"closing_warning": sum(1 for j in jobs if j.get("closing_status") == "warning"),
"has_closing": sum(1 for j in jobs if j.get("closing_date")),
"statuses": statuses,
"locations": locations,
"sources": sources,
"salary_min": min(salaries) if salaries else None,
"salary_max": max(salaries) if salaries else None,
"refreshed_at": data.get("refreshed_at", ""),
}
return self._json_response(stats)
elif path == "/api/refresh":
data = refresh_cache(force=True)
return self._json_response({"ok": True, "total": data["total"]})
elif path.startswith("/api/jobs/") and path.endswith("/refresh"):
data = refresh_cache(force=True)
return self._json_response({"ok": True, "total": data["total"]})
# Serve draft files (cover letters) — preserve extension for browser rendering
# Support both /drafts/ and /jobs/drafts/ paths (Tailscale Serve uses /jobs/drafts/ from prompt)
drafts_path = None
if path.startswith("/drafts/"):
safe_path = os.path.normpath(path.lstrip("/"))
drafts_path = os.path.join(DRAFTS_DIR, os.path.relpath(safe_path, "drafts"))
elif path.startswith("/jobs/drafts/"):
safe_path = os.path.normpath(path.lstrip("/"))
drafts_path = os.path.join(DRAFTS_DIR, os.path.relpath(safe_path, "jobs/drafts"))
if drafts_path:
if os.path.isfile(drafts_path) and os.path.realpath(drafts_path).startswith(os.path.realpath(DRAFTS_DIR)):
ext = os.path.splitext(drafts_path)[1].lower()
ctype = "text/plain; charset=utf-8"
if ext == ".html":
ctype = "text/html; charset=utf-8"
elif ext == ".json":
ctype = "application/json; charset=utf-8"
elif ext == ".docx":
ctype = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Disposition", f"inline; filename=\"{os.path.basename(drafts_path)}\"")
self.end_headers()
if ext == ".docx":
with open(drafts_path, "rb") as f:
self.wfile.write(f.read())
else:
with open(drafts_path) as f:
self.wfile.write(f.read().encode("utf-8"))
return
else:
return self._json_response({"error": "Not found"}, 404)
# Default: serve static file
return super().do_GET()
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length else b"{}"
try:
payload = json.loads(body)
except json.JSONDecodeError:
return self._json_response({"error": "Invalid JSON"}, 400)
# POST /api/refresh — force refresh from Notion
if path == "/api/refresh":
data = refresh_cache(force=True)
return self._json_response({"ok": True, "total": data["total"], "refreshed_at": data["refreshed_at"]})
# POST /api/jobs/<id>/update — update a job
match = re.match(r"^/api/jobs/([a-f0-9\-]+)/update$", path)
if match:
job_id = match.group(1)
result = update_job_status(job_id, payload)
if "error" in result:
return self._json_response(result, 400)
# Refresh cache after update
refresh_cache(force=True)
return self._json_response({"ok": True})
# POST /api/jobs/<id>/cover-letter/regenerate — regenerate cover letter with feedback
match_cl = re.match(r"^/api/jobs/([a-f0-9\-]+)/cover-letter/regenerate$", path)
if match_cl:
job_id = match_cl.group(1)
feedback = payload.get("feedback", "Make it stronger")
script = os.path.expanduser("~/.hermes/scripts/regenerate_cover_letter.py")
result = subprocess.run(
["python3", script, job_id, feedback],
capture_output=True, text=True, timeout=120
)
try:
cl_result = json.loads(result.stdout)
if "error" in cl_result:
return self._json_response(cl_result, 400)
# Update Notion with the new URL
update_result = update_job_status(job_id, {"cover_letter_url": cl_result["url"]})
if "error" in update_result:
return self._json_response({"error": "Draft saved but Notion update failed", "cl_result": cl_result, "notion_error": update_result}, 400)
# Refresh cache
refresh_cache(force=True)
return self._json_response({"ok": True, "url": cl_result["url"], "file": cl_result["file"]})
except json.JSONDecodeError:
return self._json_response({"error": "Regeneration failed", "output": result.stdout[:500]}, 400)
# POST /api/jobs/bulk-update — update multiple jobs
if path == "/api/jobs/bulk-update":
ids = payload.get("ids", [])
updates = payload.get("updates", {})
results = []
for jid in ids:
r = update_job_status(jid, updates)
results.append({"id": jid, "ok": "error" not in r})
refresh_cache(force=True)
return self._json_response({"ok": True, "results": results})
return self._json_response({"error": "Not found"}, 404)
def do_OPTIONS(self):
"""CORS preflight."""
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def _json_response(self, data, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def log_message(self, format, *args):
"""Quiet logs — skip static file noise."""
msg = format % args
if "/api/" in msg:
print(f"[API] {msg}", file=sys.stderr)
else:
# Keep it very quiet for static files
pass
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
# Ensure dashboard dir exists
os.makedirs(DASHBOARD_DIR, exist_ok=True)
# Pre-warm the cache
print("Pre-warming job cache from Notion...", file=sys.stderr)
refresh_cache(force=True)
print(f"Cache loaded: {_jobs_cache['total']} jobs", file=sys.stderr)
server = HTTPServer(("0.0.0.0", PORT), DashboardHandler)
print(f"Dashboard server running on http://127.0.0.1:{PORT}", file=sys.stderr)
print(f"Dashboard URL: https://hermes.kangaroo-eel.ts.net/jobs", file=sys.stderr)
print(f"API: GET /api/jobs /api/stats /api/refresh", file=sys.stderr)
print(f"API: POST /api/jobs/<id>/update /api/jobs/bulk-update", file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...", file=sys.stderr)
server.server_close()
if __name__ == "__main__":
main()

509
regenerate_cover_letter.py Normal file
View File

@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""Regenerate a cover letter using an LLM, incorporating user feedback.
Called by the dashboard server when the user clicks "Regenerate".
Args:
job_id: Notion page ID of the job
feedback: User's instructions for the revision
Outputs:
Prints JSON with {"url": "...", "file": "..."} on success
Prints JSON with {"error": "..."} on failure
"""
import json, os, sys, re, subprocess
from datetime import datetime
HERMES_DIR = os.path.expanduser("~/.hermes")
DRAFTS_DIR = os.path.join(HERMES_DIR, "radar-ref/drafts")
RESUMES_DIR = os.path.join(HERMES_DIR, "radar-ref/resumes")
COVER_LETTER_DIR = os.path.join(HERMES_DIR, "radar-ref/cover-letters")
CACHE_FILE = os.path.join(HERMES_DIR, "dashboard/jobs_cache.json")
# ── Load context ───────────────────────────────────────────────────────────
def load_job(job_id):
"""Look up a job by its Notion page ID in the cache."""
with open(CACHE_FILE) as f:
cache = json.load(f)
for job in cache.get("jobs", []):
if job["id"] == job_id:
return job
return None
def read_file_first_line_or_none(path):
if os.path.isfile(path):
with open(path) as f:
return f.read().strip()
return None
def find_first_file(dir_path):
"""Return first .pdf or .txt in a directory, or None."""
if not os.path.isdir(dir_path):
return None
for f in sorted(os.listdir(dir_path)):
if f.endswith((".pdf", ".txt", ".md")):
return os.path.join(dir_path, f)
return None
def slugify(text):
"""Turn company + role into a safe filename."""
s = text.lower().strip()
s = re.sub(r'[^\w\s-]', '', s)
s = re.sub(r'[-\s]+', '-', s)
return s[:80]
# ── LLM call ───────────────────────────────────────────────────────────────
def call_llm(system_prompt, user_prompt):
"""Call the OmniRoute cover-letter-writer model combo. Returns response text."""
payload = {
"model": "cover-letter-writer",
"stream": False,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": 0.7,
"max_tokens": 2000
}
cmd = [
"curl", "-s",
"http://omniroute:20128/v1/chat/completions",
"-H", "Content-Type: application/json",
"-H", "Authorization: Bearer sk-1234",
"-d", json.dumps(payload)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
try:
data = json.loads(result.stdout)
return data["choices"][0]["message"]["content"]
except (KeyError, json.JSONDecodeError) as e:
error_text = result.stdout[:500] if result.stdout.strip() else "Empty response"
return f"[Error: {e}{error_text}]"
def parse_cover_letter(text, company="", role="", location=""):
"""Parse cover letter text into structured parts for HTML template."""
lines = text.strip().split('\n')
# Find subject line
subject = ""
first_idx = 0
for i, line in enumerate(lines):
s = line.strip()
if s.startswith("Subject:") or (s.startswith("**Subject:") and s.endswith("**")):
subject = s.replace("**","").replace("Subject:","").replace("**","").strip()
first_idx = i + 1
break
# Find opening paragraph (between salutation and bullets)
opening = ""
bullets = []
closing = ""
# Skip everything before "Dear Hiring Manager," — contact block + date belong
# in the template header, not in the parsed opening paragraph.
salutation_idx = None
for i, line in enumerate(lines[first_idx:], start=first_idx):
if line.strip().lower().startswith("dear ") and "hiring manager" in line.lower():
salutation_idx = i
break
parse_start = salutation_idx + 1 if salutation_idx is not None else first_idx
in_bullets = False
bridging = False
for line in lines[parse_start:]:
stripped = line.strip()
if not stripped:
continue
# Detect bullet markers
is_bullet = (
stripped.startswith('-') or
stripped.startswith('') or
(len(stripped) > 2 and stripped[0].isdigit() and stripped[1] in '.):')
)
# Detect salutation
if stripped.startswith("Dear ") and "Dear Hiring Manager" in stripped:
in_bullets = False
bridging = False
continue
if is_bullet:
in_bullets = True
bridging = False
# Clean bullet marker
bullet_text = stripped.lstrip('-• ').strip()
# Remove markdown bold markers per-line so the plain letter reads cleanly
bullet_text = bullet_text.replace('**', '')
# Remove leading numbering like "1. ", "2. "
if len(bullet_text) > 2 and bullet_text[0].isdigit() and bullet_text[1] == '.':
bullet_text = bullet_text[2:].strip()
bullets.append(bullet_text)
elif in_bullets and not is_bullet:
in_bullets = False
bridging = False
if not stripped.startswith("Dear ") and not stripped.startswith("Dear Hiring"):
closing += stripped + " "
elif not in_bullets and not stripped.startswith("Dear ") and not stripped.startswith("Dear Hiring"):
# Already past bullets — rest belongs to closing, not opening
if closing:
if "regards" in stripped.lower() or stripped.startswith("Sincerely") or stripped.startswith("Kind regards"):
continue
if stripped.lower() in ["sincerely,", "kind regards,", "regards,"] or stripped.startswith("Anthony Martin"):
continue
closing += stripped + " "
continue
# Still in opening section (no bullets seen yet)
if "regards" in stripped.lower() or stripped.startswith("Sincerely") or stripped.startswith("Kind regards"):
continue
if stripped.lower() in ["sincerely,", "kind regards,", "regards,"] or stripped.startswith("Anthony Martin"):
continue
# Bridge text before bullets: drop it
bridging_texts = ["my experience includes:", "my experience includes",
"these include:", "these include",
"key highlights:", "key highlights",
"relevant experience includes:", "relevant experience includes",
"relevant achievements include:", "relevant achievements include",
"my relevant experience includes:"]
lower = stripped.lower().rstrip(":")
if lower in bridging_texts or any(stripped.lower().startswith(b) for b in ["my experience ", "i bring ", "i would ", "relevant experience ", "relevant achievements ", "key achievements "]):
bridging = True
continue
if bridging:
continue
opening += stripped + " "
# If the line just added ends with a bridge phrase, strip it
opening_lower = opening.lower()
for bt in bridging_texts:
bt_stripped = bt.rstrip(":")
if opening_lower.strip().rstrip(".;: ").endswith(bt_stripped):
# Find actual position of bridge text in opening, then strip it
idx = opening_lower.rfind(bt_stripped)
if idx >= 0:
opening = opening[:idx].strip() + " "
break
if not opening and len(lines) > 3:
opening = lines[3].strip() if len(lines) > 3 else ""
# Remove markdown bold markers from opening/closing
opening = opening.replace('**', '')
closing = closing.replace('**', '')
return {
"subject": subject or f"Application for {role} at {company}",
"opening": opening.strip()[:800],
"bullets": bullets[:5],
"closing": closing.strip(),
"date": datetime.now().strftime("%d %B %Y"),
"company": company,
"role": role,
"location": location or "Perth, WA"
}
def render_cover_letter_html(structured, txt_url):
"""Render structured cover letter data as HTML using the template."""
try:
template_path = os.path.expanduser("~/.hermes/dashboard/templates/cover-letter.html")
with open(template_path) as f:
template = f.read()
# Build recipient block
recipient = [structured.get("company", ""), structured.get("location", "Perth, WA")]
# Format bullets
bullets_html = ""
for bullet in structured.get("bullets", []):
bullets_html += f"<li>{bullet}</li>\n "
if not bullets_html:
bullets_html = "<li>" + structured.get("opening", "")[:200] + "</li>"
# Replace template variables
html = template.replace("{{title}}", structured.get("subject", "Cover Letter"))
html = html.replace("{{date}}", structured.get("date", ""))
html = html.replace("{{recipient_block}}", "\n".join(recipient))
html = html.replace("{{opening}}", structured.get("opening", ""))
html = html.replace("{{bullets}}", bullets_html)
html = html.replace("{{closing}}", structured.get("closing", ""))
html = html.replace("{{txt_url}}", txt_url)
html = html.replace("{{cover_letter_docx_url}}", txt_url.replace(".txt", ".docx"))
return html
except Exception as e:
return None
def update_notion_cover_letter(page_id, url):
"""Update the 'Cover Letter' URL property on a Notion page."""
env_path = os.path.expanduser("~/.hermes/.env")
key = None
with open(env_path) as f:
for line in f:
if line.startswith("NOTION_API_KEY="):
key = line.split("=", 1)[1].strip()
break
if not key:
return False
payload = {
"properties": {
"Cover Letter": {
"type": "url",
"url": url
}
}
}
cmd = [
"curl", "-s", "-X", "PATCH",
f"https://api.notion.com/v1/pages/{page_id}",
"-H", f"Authorization: Bearer {key}",
"-H", "Content-Type: application/json",
"-H", "Notion-Version: 2022-06-28",
"-d", json.dumps(payload)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
try:
data = json.loads(result.stdout)
return data.get("object") == "page"
except:
return False
# ── Main ───────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 3:
print(json.dumps({"error": "Usage: regenerate_cover_letter.py <job_id> <feedback>"}))
sys.exit(1)
job_id = sys.argv[1]
feedback = sys.argv[2]
job = load_job(job_id)
if not job:
print(json.dumps({"error": f"Job {job_id} not found in cache"}))
sys.exit(1)
company = job.get("name", "Unknown")
role = job.get("role", "Unknown")
location = job.get("location", "Perth, WA")
salary = job.get("salary")
salary_str = f"${salary:,}" if salary else "competitive"
notes = job.get("notes", "")
# Build the filename
safe_name = slugify(f"{company}-{role}")
ts = datetime.now().strftime("%Y%m%d")
filename = f"cl-{safe_name}-{ts}.txt"
filepath = os.path.join(DRAFTS_DIR, filename)
# Read resume (just the first one found)
resume_file = find_first_file(RESUMES_DIR)
resume_text = ""
if resume_file:
if resume_file.endswith(".txt"):
with open(resume_file) as f:
resume_text = f.read()
elif resume_file.lower().endswith(".pdf"):
try:
import pdfplumber
with pdfplumber.open(resume_file) as pdf:
pages = [p.extract_text() or "" for p in pdf.pages]
resume_text = "\n".join(pages)
except Exception:
resume_text = f"[Resume file: {os.path.basename(resume_file)} — pdfplumber unavailable]"
elif resume_file.lower().endswith(".docx"):
try:
from docx import Document
doc = Document(resume_file)
resume_text = "\n".join(p.text for p in doc.paragraphs)
except Exception:
resume_text = f"[Resume file: {os.path.basename(resume_file)} — python-docx unavailable]"
# Read existing cover letter if re-generating
existing_cl = ""
# System prompt for cover letter generation
system_prompt = """You are a professional cover letter writer. Write concise,
impactful cover letters for Anthony Martin, a Perth-based marketing and communications
professional.
STYLE GUIDE (from Anthony's real cover letters):
- Professional, warm, and direct — not overly formal or stiff
- Open with a personal connection or belief statement about the company/role
- Use bullet points (3-4) with specific metrics and achievements
- Reference relevant experience from this resume only
ANTHONY'S ACTUAL CAREER HISTORY (ABSOLUTE CONSTRAINT — DO NOT DEVIATE):
- Pacific Energy (Kewdale, Perth, WA): Jun 2025 Jul 2026 — Marketing Advisor. Corporate marketing, digital content, technical communications, major events, marketing AI strategy. Led website that won Science and Sustainability Website of the Year at 2026 Australian Web Awards.
- Horizon Power (Perth, WA): 2019 Sep 2024 — Marketing Specialist. Annual budgets >$2M, 80%+ customer satisfaction, WA EV Network marketing, Bright Horizons STEM program, regulatory compliance communications.
- Synergy (Perth, WA): 2010 2018 — Multiple commercial/marketing roles. SolarReturn (40% sales increase), Synergy Sellback Program (150% above target), $2M+ billing resolution, 1M+ customer communications framework.
CRITICAL RULES:
- The THREE employers above are the ONLY ones Anthony has worked for.
- NEVER mention Lotterywest, Tourism WA, Water Corporation, Department of Creative Industries, Empire Home, SVARA, Lenox Hill, Fairclough, Recruit Right, a&co Recruitment, Catholic Education WA, THE resources HUB — or any other employer — even as examples, comparisons, or "organisations including..."
- If you list employers in the opening paragraph, ONLY list: Pacific Energy, Horizon Power, Synergy.
- Treat any employer not explicitly listed here as FORBIDDEN.
KEY ACHIEVEMENTS (use these exact metrics):
- Pacific Energy 2026 Australian Web Awards — Science and Sustainability Website of the Year
- Managed annual marketing budgets >$2M at Horizon Power
- 80%+ customer satisfaction ratings
- 40% sales increase (SolarReturn at Synergy, year one)
- 150% above target (Synergy Sellback Program sales)
- $1.3M billing discrepancy resolution at Synergy
- 35% improvement in conversion rates
- 20% reduction in inquiry-to-sale timeframes
- Company-wide communications framework reaching 1M+ customers
- WA EV Network marketing leadership
- Created Bright Horizons STEM program (remote schools)
BULLET STRUCTURE:
- 3-4 bullets max
- Each bullet: action verb + context + measurable outcome
- Draw from the real achievements above — do NOT invent companies, roles, or metrics
FORMAT:
- Subject line with the role and company
- Salutation (Dear Hiring Manager,)
- Opening paragraph — why this role + company (1-2 sentences)
- 3-4 bullet point achievements (draw from actual experience)
- Closing paragraph — enthusiasm + call to action (1-2 sentences)
- Signature: Anthony Martin"""
user_prompt = f"""Write a tailored cover letter for this role:
COMPANY: {company}
ROLE: {role}
LOCATION: {location}
SALARY: {salary_str}
ADDITIONAL NOTES: {notes}
RESUME HIGHLIGHTS:
{resume_text}
FEEDBACK / REVISION INSTRUCTIONS:
{feedback}
IMPORTANT: Return ONLY the cover letter text — no commentary, no meta text. The letter must be ready to send."""
# Call LLM
cl_text = call_llm(system_prompt, user_prompt)
# Save
os.makedirs(DRAFTS_DIR, exist_ok=True)
with open(filepath, "w") as f:
f.write(cl_text)
# Build the URLs (both .txt raw and rendered HTML)
tailscale_host = "https://hermes.kangaroo-eel.ts.net"
base = f"cl-{safe_name}-{ts}"
cl_url_txt = f"{tailscale_host}/jobs/drafts/{base}.txt"
cl_url_html = f"{tailscale_host}/jobs/drafts/{base}.html"
# Try to parse structured JSON from the plain text
structured = parse_cover_letter(cl_text, company, role, location)
# Save structured JSON for template rendering
json_path = os.path.join(DRAFTS_DIR, base + ".json")
with open(json_path, "w") as f:
json.dump(structured, f, indent=2)
# Render HTML version if we have structured data
html_path = os.path.join(DRAFTS_DIR, base + ".html")
html_doc = render_cover_letter_html(structured, cl_url_txt)
if html_doc:
with open(html_path, "w") as f:
f.write(html_doc)
# Generate .docx using python-docx builder
docx_script = os.path.expanduser("~/.hermes/scripts/build_cover_letter_docx.py")
docx_result = subprocess.run(
["python3", docx_script, base],
capture_output=True, text=True, timeout=30, cwd=DRAFTS_DIR
)
if docx_result.returncode != 0:
print(f"[Warning] DOCX generation failed: {docx_result.stderr[:200]}")
# Build the URLs (both .txt raw and rendered HTML)
tailscale_host = "https://hermes.kangaroo-eel.ts.net"
cl_url_txt = f"{tailscale_host}/jobs/drafts/{base}.txt"
cl_url_html = f"{tailscale_host}/jobs/drafts/{base}.html"
cl_url_docx = f"{tailscale_host}/jobs/drafts/{base}.docx"
# Update Notion with the HTML URL (preferred view)
update_notion_cover_letter(job_id, cl_url_html)
print(json.dumps({
"url": cl_url_html,
"txt_url": cl_url_txt,
"docx_url": cl_url_docx,
"file": filepath,
"company": company,
"role": role,
"structured": structured is not None
}))
if __name__ == "__main__":
main()
def backfill_from_txt(txt_path):
"""Regenerate HTML/DOCX from an existing .txt file using the canonical parser."""
import subprocess
with open(txt_path, "r", encoding="utf-8") as f:
text = f.read()
# Parse using canonical function
structured = parse_cover_letter(text, company="", role="", location="")
base = txt_path[:-4]
json_path = base + ".json"
html_path = base + ".html"
docx_path = base + ".docx"
# Add URLs
txt_url = f"/jobs/drafts/{os.path.basename(txt_path)}"
structured["txt_url"] = txt_url
structured["html_url"] = txt_url.replace(".txt", ".html")
structured["docx_url"] = txt_url.replace(".txt", ".docx")
# Save JSON
with open(json_path, "w", encoding="utf-8") as f:
json.dump(structured, f, indent=2)
# Render HTML using canonical function
html = render_cover_letter_html(structured, txt_url)
if html:
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
# Generate DOCX
docx_script = os.path.expanduser("~/.hermes/scripts/build_cover_letter_docx.py")
subprocess.run(["python3", docx_script, json_path], capture_output=True, timeout=60)
return os.path.exists(html_path) and os.path.exists(docx_path)
if __name__ == "__main__":
import sys
# Check for backfill mode
if len(sys.argv) == 2 and sys.argv[1].endswith(".txt"):
txt_file = sys.argv[1]
print(f"Backfilling {txt_file}...")
success = backfill_from_txt(txt_file)
print(f" {'' if success else ''} {os.path.basename(txt_file)}")
sys.exit(0 if success else 1)
# Normal mode: generate from Notion job
if len(sys.argv) < 2:
print("Usage: python3 regenerate_cover_letter.py <notion_job_id> [feedback]")
print(" or: python3 regenerate_cover_letter.py <path/to/cover-letter.txt>")
sys.exit(1)
job_id = sys.argv[1]
feedback = sys.argv[2] if len(sys.argv) > 2 else ""
main(job_id, feedback)

164
templates/cover-letter.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Georgia, 'Times New Roman', serif;
background: #f5f5f0;
padding: 60px 20px;
color: #1a1a1a;
line-height: 1.6;
}
.page {
max-width: 780px;
margin: 0 auto;
background: #fff;
padding: 80px 90px;
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
border-top: 4px solid #1a472a;
}
.header {
border-bottom: 2px solid #1a472a;
padding-bottom: 18px;
margin-bottom: 32px;
}
.name {
font-size: 22px;
font-weight: bold;
color: #1a472a;
letter-spacing: 0.5px;
}
.contact {
font-size: 13px;
color: #555;
margin-top: 4px;
font-family: 'Helvetica Neue', Arial, sans-serif;
letter-spacing: 0.2px;
}
.date {
font-size: 14px;
color: #333;
margin-bottom: 28px;
font-family: 'Helvetica Neue', Arial, sans-serif;
}
.recipient {
font-size: 14px;
color: #333;
margin-bottom: 18px;
font-family: 'Helvetica Neue', Arial, sans-serif;
line-height: 1.5;
}
.salutation {
font-size: 15px;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 18px;
}
.body-text {
font-size: 15px;
color: #1a1a1a;
margin-bottom: 14px;
text-align: justify;
}
.bullets {
margin: 14px 0 14px 22px;
padding: 0;
}
.bullets li {
font-size: 15px;
color: #1a1a1a;
margin-bottom: 10px;
padding-left: 8px;
line-height: 1.55;
}
.bullets li::marker {
color: #1a472a;
}
.closing {
font-size: 15px;
color: #1a1a1a;
margin-top: 20px;
margin-bottom: 30px;
}
.signature {
font-size: 15px;
color: #1a1a1a;
line-height: 1.8;
}
.actions {
max-width: 780px;
margin: 24px auto 0;
display: flex;
gap: 12px;
justify-content: flex-end;
font-family: 'Helvetica Neue', Arial, sans-serif;
}
.actions a, .actions button {
padding: 9px 18px;
border-radius: 6px;
font-size: 13px;
text-decoration: none;
cursor: pointer;
border: none;
font-family: inherit;
}
.btn-primary {
background: #1a472a;
color: #fff;
}
.btn-primary:hover {
background: #143520;
}
.btn-secondary {
background: #e8e8e3;
color: #333;
}
.btn-secondary:hover {
background: #d8d8d0;
}
@media (max-width: 600px) {
.page { padding: 40px 28px; }
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="name">Anthony Martin</div>
<div class="contact">90 Lansdowne Road, Kensington &nbsp;|&nbsp; 0410 349 137 &nbsp;|&nbsp; anthony@martinwa.org &nbsp;|&nbsp; linkedin.com/in/anthonymartin1987</div>
</div>
<div class="date">{{date}}</div>
<div class="recipient">
{{recipient_block}}
</div>
<div class="salutation">Dear Hiring Manager,</div>
<div class="body-text">{{opening}}</div>
<ul class="bullets">
{{bullets}}
</ul>
<div class="closing">{{closing}}</div>
<div class="signature">
Sincerely,<br><br>
Anthony Martin
</div>
</div>
<div class="actions">
<a class="btn-secondary" href="/jobs">← Back to dashboard</a>
<a class="btn-secondary" href="{{txt_url}}">Raw .txt</a>
<button class="btn-primary" onclick="window.print()">Print / Save PDF</button>
<a class="btn-primary" href="{{cover_letter_docx_url}}" download>⬇ Word .docx</a>
</div>
</body>
</html>