feat: implement task queue statistics and add UI templates for job management and visualization.

This commit is contained in:
2026-08-15 00:00:41 -07:00
parent 5510e3ac82
commit 286278c1ec
12 changed files with 1144 additions and 644 deletions
+301
View File
@@ -0,0 +1,301 @@
package ui
const jobsTabHTML = `{{ define "jobsTab" }}
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<h2 class="text-base font-bold text-white">Job Opportunities</h2>
<p class="text-xs text-slate-400 mt-0.5">All ingested postings · AI-scored · sortable</p>
</div>
<div class="flex items-center gap-2 flex-wrap">
<input id="job-filter-text" type="search" placeholder="Search title, company…"
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500 w-48 placeholder:text-slate-500">
<!-- Filter order: most frequent → least frequent use -->
<select id="job-filter-qual"
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
<option value="default">✅ Qualified (Default)</option>
<option value="not-qualified">❌ Not Qualified</option>
<option value="saved">💾 Saved / In Pipeline</option>
<option value="all">🌐 All Jobs</option>
<option value="opted-out">🚫 Opted-out</option>
</select>
<button hx-get="/jobs" hx-target="#jobs-container" hx-swap="innerHTML"
class="px-3 py-1.5 text-xs font-semibold text-indigo-400 hover:text-indigo-300 bg-slate-800 border border-slate-700 rounded-lg transition-colors">
↺ Refresh
</button>
</div>
</div>
<!-- Job List Container (HTMX auto-refreshed every 12s) -->
<div id="jobs-container"
hx-get="/jobs" hx-trigger="refresh" hx-swap="innerHTML">
{{ template "jobs" .Jobs }}
</div>
</div>
{{ end }}`
const jobListHTML = `{{ define "jobs" }}
<div class="overflow-x-auto rounded-xl border border-slate-800">
<table id="jobs-table" class="w-full text-left text-xs text-slate-300">
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
<tr>
<th class="px-4 py-3" data-sort="title">Job Title &amp; Company</th>
<th class="px-4 py-3 text-center">Link</th>
<th class="px-4 py-3" data-sort="status">Analysis</th>
<th class="px-4 py-3" data-sort="score">Score &amp; Qualification</th>
<th class="px-4 py-3">Reason / Tags</th>
<th class="px-4 py-3" data-sort="date">Crawled</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
{{ range . }}
{{- $qual := "pending" -}}
{{- if .Analysis }}{{ if .Analysis.IsQualified }}{{ $qual = "qualified" }}{{ else }}{{ $qual = "not-qualified" }}{{ end }}{{ end -}}
{{- $appClass := "none" -}}
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}}
<tr class="hover:bg-slate-800/40 transition-colors group" data-row data-qual="{{ $qual }}" data-app="{{ $appClass }}">
<!-- Title & Company -->
<td class="px-4 py-3 font-medium" data-sort-val="{{ .Title }}">
<button hx-get="/jobs/{{ .ID }}"
hx-target="#detail-modal-body"
hx-swap="innerHTML"
onclick="openDetailModal()"
class="text-left font-bold text-white group-hover:text-indigo-300 hover:underline transition-colors block truncate max-w-xs">
{{ .Title }}
</button>
<div class="flex items-center space-x-2 mt-0.5 text-[11px] text-slate-400">
<span class="font-semibold text-indigo-400">{{ .Company }}</span>
<span>&bull;</span>
<span>📍 {{ .Location }}</span>
</div>
</td>
<!-- External Link -->
<td class="px-4 py-3 text-center">
<a href="{{ .ApplyURL }}" target="_blank" title="Open original job posting"
class="inline-flex items-center justify-center w-7 h-7 rounded bg-slate-800 hover:bg-slate-700 text-indigo-400 hover:text-indigo-300 border border-slate-700 transition-colors">
🔗
</a>
</td>
<!-- Analysis Status -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ .Analysis.Status }}{{ else }}UNANALYZED{{ end }}">
{{ if .Analysis }}
{{ if eq .Analysis.Status "PARSED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">PARSED</span>
{{ else if eq .Analysis.Status "PENDING" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">PENDING</span>
{{ else if eq .Analysis.Status "DONE" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">DONE</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-500/20 text-slate-400 border border-slate-600/30">{{ .Analysis.Status }}</span>
{{ end }}
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-slate-800 text-slate-500 border border-slate-700">UNANALYZED</span>
{{ end }}
</td>
<!-- Score & Qualification -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
{{ if .Analysis }}
{{ if .Analysis.IsQualified }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}">
★ {{ printf "%.0f" .Analysis.Score }}
</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
✗ Not Qualified
</span>
{{ end }}
{{ else }}
<span class="text-slate-500 text-[11px]">–</span>
{{ end }}
</td>
<!-- Reason / Matched Tags -->
<td class="px-4 py-3 max-w-[180px]">
{{ if .App }}{{ if eq .App.Status "OPTED_OUT" }}
<span class="text-[10px] text-slate-500 italic">Opted out</span>
{{ end }}{{ end }}
{{ if .Analysis }}
{{ if .Analysis.IsQualified }}
<div class="flex flex-wrap gap-1">
{{ range parseJSON .Analysis.MatchedHighlights }}
<span class="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 text-[10px] font-medium border border-emerald-500/20">{{ . }}</span>
{{ else }}
<span class="text-slate-500 text-[10px] italic">No tags</span>
{{ end }}
</div>
{{ else }}
{{ $missing := parseJSON .Analysis.MissingHighlights }}
{{ if $missing }}
<span class="text-[10px] text-rose-400/90 leading-snug line-clamp-2" title="{{ index $missing 0 }}">{{ index $missing 0 }}</span>
{{ end }}
{{ end }}
{{ end }}
</td>
<!-- Date -->
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .CrawledAt.Unix }}">
{{ formatDate .CrawledAt }}
</td>
<!-- Actions: Save + Opt-out OR status badge -->
<td class="px-4 py-3 text-right">
{{ if .App }}
{{ if eq .App.Status "OPTED_OUT" }}
<!-- Opted out: show undo button -->
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
hx-target="#jobs-container"
hx-swap="innerHTML"
title="Undo opt-out"
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 border border-slate-700 text-[10px] transition-colors">
↩ Undo
</button>
{{ else }}
<!-- In pipeline -->
<span class="px-2 py-1 rounded bg-violet-500/20 text-violet-300 text-[10px] font-bold border border-violet-500/30">
{{ .App.Status }}
</span>
{{ end }}
{{ else }}
<!-- No app: show Save + Opt-out -->
<div class="flex items-center justify-end gap-1.5">
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
hx-target="#jobs-container"
hx-swap="innerHTML"
class="px-2.5 py-1 rounded bg-indigo-700 hover:bg-indigo-600 text-indigo-100 border border-indigo-600 text-[10px] font-semibold transition-colors">
+ Save
</button>
<button hx-post="/jobs/{{ .ID }}/opt-out"
hx-target="#jobs-container"
hx-swap="innerHTML"
title="Hide this job"
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-rose-900/40 text-slate-500 hover:text-rose-400 border border-slate-700 text-[10px] transition-colors">
🚫
</button>
</div>
{{ end }}
</td>
</tr>
{{ else }}
<tr>
<td colspan="7" class="px-4 py-12 text-center text-slate-500">
<p class="text-lg mb-2">📭</p>
<p>No job postings found. Click <strong>🔍 Discover</strong> in the bottom bar to start crawling.</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}`
const jobDetailHTML = `{{ define "jobDetail" }}
<div class="space-y-5">
<!-- Header -->
<div class="flex items-start justify-between border-b border-slate-800 pb-4">
<div class="flex-1 min-w-0 pr-4">
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
<h2 class="text-xl font-extrabold text-white mt-0.5 leading-tight">{{ .Title }}</h2>
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} &bull; Ingested {{ formatDate .CrawledAt }}</p>
</div>
<button onclick="closeDetailModal()" class="flex-shrink-0 text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
</div>
{{ if .Analysis }}
<!-- Score + Status Grid -->
<div class="grid grid-cols-2 gap-3">
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Match Score</span>
{{ if .Analysis.IsQualified }}
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-sm font-medium text-slate-400">/ 100</span></span>
{{ else }}
<span class="text-base font-bold text-rose-400 mt-1 block">Not Qualified</span>
{{ end }}
</div>
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Analysis Status</span>
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
</div>
</div>
<!-- Highlights -->
<div class="space-y-4">
{{ if .Analysis.IsQualified }}
<div>
<h4 class="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2">✓ Matched Highlights</h4>
<div class="flex flex-wrap gap-2">
{{ range parseJSON .Analysis.MatchedHighlights }}
<span class="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-300 text-xs font-semibold border border-emerald-500/30">{{ . }}</span>
{{ else }}
<span class="text-slate-500 text-xs italic">None identified</span>
{{ end }}
</div>
</div>
{{ end }}
<div>
<h4 class="text-xs font-bold text-rose-400 uppercase tracking-wider mb-2">! {{ if .Analysis.IsQualified }}Gaps / Missing Skills{{ else }}Disqualification Reasons{{ end }}</h4>
<div class="flex flex-wrap gap-2">
{{ range parseJSON .Analysis.MissingHighlights }}
<span class="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-300 text-xs font-semibold border border-rose-500/30">{{ . }}</span>
{{ else }}
<span class="text-slate-500 text-xs italic">None identified</span>
{{ end }}
</div>
</div>
{{ if .Analysis.IsQualified }}
<div>
<h4 class="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">💡 Resume Tailoring Tips</h4>
<ul class="space-y-2">
{{ range parseJSON .Analysis.TailoringSuggestions }}
<li class="p-3 rounded-lg bg-slate-800/80 border border-slate-700/60 text-xs text-slate-200 leading-relaxed">&bull; {{ . }}</li>
{{ else }}
<li class="text-slate-500 text-xs italic">None</li>
{{ end }}
</ul>
</div>
{{ end }}
</div>
{{ end }}
<!-- Job Description (stored as Markdown) -->
<div class="border-t border-slate-800 pt-4">
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Job Description</h4>
<div class="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-300 max-h-72 overflow-y-auto leading-relaxed custom-scrollbar whitespace-pre-wrap font-mono">
{{ rawContent .RawContent }}
</div>
</div>
<!-- Actions Footer -->
<div class="flex items-center justify-between border-t border-slate-800 pt-4">
<a href="{{ .ApplyURL }}" target="_blank"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-bold text-white rounded-lg shadow-md transition-all">
Apply External ↗
</a>
<div class="flex space-x-2">
<button hx-post="/jobs/{{ .ID }}/analyze"
hx-target="#detail-modal-body"
hx-swap="innerHTML"
class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 rounded-lg border border-slate-700">
🔄 Re-run LLM
</button>
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "APPLIED"}'
hx-target="#jobs-container"
hx-swap="innerHTML"
onclick="switchTab('apply'); closeDetailModal();"
class="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white rounded-lg shadow-md">
+ Mark Applied
</button>
</div>
</div>
</div>
{{ end }}`