Initial commit: job dashboard server with evening stand-down, Notion sync, cover letter generation
This commit is contained in:
137
backfill_cover_letters.py
Executable file
137
backfill_cover_letters.py
Executable 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
491
evening.html
Normal 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 · ${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 – $${(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 · 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 · 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
184
evening_standdown.py
Normal 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
232
index.html
Normal 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
406
job_dashboard_server.py
Normal 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
509
regenerate_cover_letter.py
Normal 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
164
templates/cover-letter.html
Normal 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 | 0410 349 137 | anthony@martinwa.org | 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>
|
||||||
Reference in New Issue
Block a user