Phase 2: Market Intel + Smart Match + Ops Timeline
This commit is contained in:
@@ -253,6 +253,104 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
data = refresh_cache(force=True)
|
||||
return self._json_response({"ok": True, "total": data["total"]})
|
||||
|
||||
# GET /api/market-intel — salary distribution + trend analysis
|
||||
elif path == "/api/market-intel":
|
||||
data = refresh_cache()
|
||||
jobs = data.get("jobs", [])
|
||||
salaries = [j["salary"] for j in jobs if j["salary"]]
|
||||
|
||||
brackets = [
|
||||
{"label": "Under $80k", "min": 0, "max": 80000},
|
||||
{"label": "$80k–$100k", "min": 80000, "max": 100000},
|
||||
{"label": "$100k–$120k", "min": 100000, "max": 120000},
|
||||
{"label": "$120k–$140k", "min": 120000, "max": 140000},
|
||||
{"label": "$140k+", "min": 140000, "max": 999999},
|
||||
]
|
||||
distribution = [{"label": b["label"], "count": sum(1 for s in salaries if b["min"] <= s < b["max"])} for b in brackets]
|
||||
|
||||
locs = {}
|
||||
for j in jobs:
|
||||
loc = j["location"] or "Unknown"
|
||||
if j["salary"]:
|
||||
locs.setdefault(loc, []).append(j["salary"])
|
||||
loc_stats = {loc: {"avg": round(sum(s)/len(s)), "min": min(s), "max": max(s), "count": len(s)} for loc, s in locs.items()}
|
||||
|
||||
months = {}
|
||||
for j in jobs:
|
||||
if j["date_added"]:
|
||||
m = j["date_added"][:7]
|
||||
months[m] = months.get(m, 0) + 1
|
||||
|
||||
sources = {}
|
||||
for j in jobs:
|
||||
src = j["source"] or "Unknown"
|
||||
s = sources.setdefault(src, {"count": 0, "salary_sum": 0, "salary_count": 0})
|
||||
s["count"] += 1
|
||||
if j["salary"]:
|
||||
s["salary_sum"] += j["salary"]
|
||||
s["salary_count"] += 1
|
||||
src_stats = {src: {"count": d["count"], "avg_salary": round(d["salary_sum"]/d["salary_count"]) if d["salary_count"] else None} for src, d in sources.items()}
|
||||
|
||||
statuses = {}
|
||||
for j in jobs:
|
||||
s = j["status"]
|
||||
statuses[s] = statuses.get(s, 0) + 1
|
||||
|
||||
return self._json_response({
|
||||
"total_tracked": len(jobs),
|
||||
"with_salary": len(salaries),
|
||||
"avg_salary": round(sum(salaries)/len(salaries)) if salaries else 0,
|
||||
"median_salary": sorted(salaries)[len(salaries)//2] if salaries else 0,
|
||||
"min_salary": min(salaries) if salaries else 0,
|
||||
"max_salary": max(salaries) if salaries else 0,
|
||||
"salary_distribution": distribution,
|
||||
"by_location": loc_stats,
|
||||
"monthly_trend": dict(sorted(months.items())),
|
||||
"by_source": src_stats,
|
||||
"by_status": statuses,
|
||||
"refreshed_at": data.get("refreshed_at", ""),
|
||||
})
|
||||
|
||||
# GET /api/jobs/<id>/match — match analysis for a job
|
||||
match_job_match = re.match(r"^/api/jobs/([a-f0-9\-]+)/match$", path)
|
||||
if match_job_match:
|
||||
job_id = match_job_match.group(1)
|
||||
data = refresh_cache()
|
||||
job = next((j for j in data.get("jobs", []) if j["id"] == job_id), None)
|
||||
if not job:
|
||||
return self._json_response({"error": "Job not found"}, 404)
|
||||
score = 50
|
||||
signals = []
|
||||
if job.get("strong_match"):
|
||||
score += 20; signals.append("Strong match flag")
|
||||
if job.get("salary"):
|
||||
score += 5; signals.append("Has salary data")
|
||||
if job.get("notes") and len(job.get("notes", "")) > 50:
|
||||
score += 10; signals.append("Detailed JD summary")
|
||||
elif job.get("notes"):
|
||||
score += 5; signals.append("Has JD summary")
|
||||
if job.get("cover_letter_url"):
|
||||
score += 10; signals.append("Cover letter exists")
|
||||
if job.get("role") and job.get("name"):
|
||||
score += 5; signals.append("Company + role populated")
|
||||
if job.get("url"):
|
||||
score += 5; signals.append("Job URL available")
|
||||
score = min(score, 99)
|
||||
return self._json_response({
|
||||
"score": score,
|
||||
"signals": signals,
|
||||
"strong_match": job.get("strong_match", False),
|
||||
"has_cl": bool(job.get("cover_letter_url")),
|
||||
"has_salary": bool(job.get("salary")),
|
||||
"has_notes": bool(job.get("notes")),
|
||||
"job_id": job_id,
|
||||
})
|
||||
|
||||
# GET /ops — serve the ops timeline page
|
||||
elif path == "/ops":
|
||||
self.path = "/ops.html"
|
||||
return super().do_GET()
|
||||
|
||||
# Serve draft files (cover letters) — preserve extension for browser rendering
|
||||
# Support both /drafts/ and /jobs/drafts/ paths (Tailscale Serve uses /jobs/drafts/ from prompt)
|
||||
drafts_path = None
|
||||
|
||||
Reference in New Issue
Block a user