feat: add job maintenance dashboard and automated stale job expiration system

This commit is contained in:
2026-08-22 01:51:43 -07:00
parent 367929e7d0
commit 7ec91f384f
12 changed files with 633 additions and 56 deletions
+45
View File
@@ -2,6 +2,7 @@ package ui
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -44,6 +45,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs)
mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks)
mux.HandleFunc("GET /api/status", s.handleGetStatus)
mux.HandleFunc("POST /api/maintenance/stale-expire", s.handleMaintenanceStaleExpire)
mux.HandleFunc("GET /api/maintenance/stats", s.handleMaintenanceStats)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -83,10 +86,16 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
}
mStats, err := s.db.GetJobMaintenanceStats()
if err != nil {
logger.Log.Warnw("Failed to get maintenance stats", "component", "UI", "error", err)
}
data := PageData{
Targets: targets,
Jobs: jobs,
Applications: apps,
MaintenanceStats: mStats,
ActiveTab: "triage",
LLMProvider: s.cfg.LLMProvider,
ToReviewCount: toReviewCount,
@@ -434,3 +443,39 @@ func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (s *Server) handleMaintenanceStaleExpire(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
days, _ := strconv.Atoi(r.FormValue("days"))
if days <= 0 {
days = 14
}
protect := r.FormValue("protect") != "false"
count, err := s.db.EnqueueExpireTasks(days, protect)
if err != nil {
logger.Log.Errorw("Failed to enqueue expire tasks", "component", "UI", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"count": count,
"message": fmt.Sprintf("Enqueued %d expire task(s) for postings not seen in %d+ days.", count, days),
})
}
func (s *Server) handleMaintenanceStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.db.GetJobMaintenanceStats()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stats)
}
+50
View File
@@ -82,3 +82,53 @@ func TestHandleDeleteTarget(t *testing.T) {
func osWriteFile(path string, data []byte) error {
return os.WriteFile(path, data, 0644)
}
func TestMaintenanceHandlers(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_ui_maintenance.db")
database, err := db.InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
// Insert job
job, _, err := database.InsertRawJob("canon-m-1", nil, "Software Engineer", "Google", "Seattle", "https://example.com/job/1", "Go backend")
if err != nil {
t.Fatalf("failed to insert raw job: %v", err)
}
// Make stale (25 days old)
_, _ = database.Exec(`UPDATE raw_jobs SET last_seen_at = datetime('now', '-25 days') WHERE id = ?`, job.ID)
cfg := &config.Config{}
server := NewServer(cfg, database, nil)
mux := http.NewServeMux()
server.RegisterRoutes(mux)
// 1. Test POST /api/maintenance/stale-expire
req := httptest.NewRequest("POST", "/api/maintenance/stale-expire", strings.NewReader("days=14&protect=true"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"status":"success"`) || !strings.Contains(w.Body.String(), `"count":1`) {
t.Errorf("unexpected body: %s", w.Body.String())
}
// 2. Test GET /api/maintenance/stats
reqStats := httptest.NewRequest("GET", "/api/maintenance/stats", nil)
wStats := httptest.NewRecorder()
mux.ServeHTTP(wStats, reqStats)
if wStats.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", wStats.Code, wStats.Body.String())
}
if !strings.Contains(wStats.Body.String(), `"total_jobs":1`) {
t.Errorf("expected stats to show total_jobs:1, got %s", wStats.Body.String())
}
}
+14
View File
@@ -19,6 +19,8 @@ const applyTabHTML = `{{ define "applyTab" }}
<option value="INTERVIEW">💬 Interview</option>
<option value="OFFER">🎉 Offer</option>
<option value="REJECTED">❌ Rejected</option>
<option value="EXPIRED">⚠️ Expired / Closed</option>
<option value="OPTED_OUT">🚫 Opted Out</option>
<option value="all">All</option>
</select>
<button hx-get="/applications" hx-target="#apply-container" hx-swap="innerHTML"
@@ -69,6 +71,15 @@ const applyTableHTML = `{{ define "applyTable" }}
<!-- Pipeline Stage (arrow indicator) -->
<td class="px-4 py-3">
{{ if eq .App.Status "EXPIRED" }}
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/15 border border-amber-500/30 text-amber-300 text-[10px] font-bold">
⚠️ Expired / Closed
</span>
{{ else if eq .App.Status "OPTED_OUT" }}
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-[10px] font-medium">
🚫 Opted Out
</span>
{{ else }}
<div class="flex items-center gap-0.5 text-[10px] font-semibold">
<span class="px-2 py-0.5 rounded-l-full {{ if eq .App.Status "SAVED" }}bg-slate-500 text-white{{ else }}bg-slate-800/60 text-slate-600{{ end }}">Saved</span>
<span class="text-slate-700">▶</span>
@@ -84,6 +95,7 @@ const applyTableHTML = `{{ define "applyTable" }}
<span class="px-2 py-0.5 rounded-r-full bg-slate-800/60 text-slate-600">Offer</span>
{{ end }}
</div>
{{ end }}
</td>
<!-- Score -->
@@ -122,6 +134,8 @@ const applyTableHTML = `{{ define "applyTable" }}
<option value="INTERVIEW" {{ if eq .App.Status "INTERVIEW" }}selected{{ end }}>💬 Interview</option>
<option value="OFFER" {{ if eq .App.Status "OFFER" }}selected{{ end }}>🎉 Offer</option>
<option value="REJECTED" {{ if eq .App.Status "REJECTED" }}selected{{ end }}>❌ Rejected</option>
<option value="EXPIRED" {{ if eq .App.Status "EXPIRED" }}selected{{ end }}>⚠️ Expired</option>
<option value="OPTED_OUT" {{ if eq .App.Status "OPTED_OUT" }}selected{{ end }}>🚫 Opted Out</option>
</select>
</td>
</tr>
+39 -16
View File
@@ -18,6 +18,7 @@ const jobsTabHTML = `{{ define "jobsTab" }}
<option value="not-qualified">❌ Not Qualified</option>
<option value="saved">💾 Saved / In Pipeline</option>
<option value="all">🌐 All Jobs</option>
<option value="expired">⚠️ Expired / Closed</option>
<option value="opted-out">🚫 Opted-out</option>
</select>
<button hx-get="/jobs" hx-target="#jobs-container" hx-swap="innerHTML"
@@ -45,7 +46,7 @@ const jobListHTML = `{{ define "jobs" }}
<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" data-sort="date">Last Updated</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
@@ -54,7 +55,7 @@ const jobListHTML = `{{ define "jobs" }}
{{- $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 -}}
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else if eq .App.Status "EXPIRED" }}{{ $appClass = "expired" }}{{ 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 -->
@@ -117,9 +118,13 @@ const jobListHTML = `{{ define "jobs" }}
<!-- 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 .App }}
{{ if eq .App.Status "OPTED_OUT" }}
<span class="text-[10px] text-slate-500 italic block">Opted out</span>
{{ else if eq .App.Status "EXPIRED" }}
<span class="text-[10px] text-amber-400/80 italic block">⚠️ No longer active</span>
{{ end }}
{{ end }}
{{ if .Analysis }}
{{ if .Analysis.IsQualified }}
<div class="flex flex-wrap gap-1">
@@ -139,8 +144,8 @@ const jobListHTML = `{{ define "jobs" }}
</td>
<!-- Date -->
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .CrawledAt.Unix }}">
{{ formatDate .CrawledAt }}
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .LastSeenAt.Unix }}">
{{ formatDate .LastSeenAt }}
</td>
<!-- Actions: Save + Opt-out OR status badge -->
@@ -148,14 +153,32 @@ const jobListHTML = `{{ define "jobs" }}
{{ 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>
<div class="flex items-center justify-end gap-1.5">
<span class="text-[10px] text-slate-500">Opted-out</span>
<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 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-[10px] transition-colors">
↩
</button>
</div>
{{ else if eq .App.Status "EXPIRED" }}
<!-- Expired -->
<div class="flex items-center justify-end gap-1.5">
<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400 text-[10px] font-bold border border-slate-700">
⚠️ EXPIRED
</span>
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
hx-target="#jobs-container"
hx-swap="innerHTML"
title="Re-open / Save"
class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-[10px] transition-colors">
↩
</button>
</div>
{{ 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">
@@ -203,7 +226,7 @@ const jobDetailHTML = `{{ define "jobDetail" }}
<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>
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} &bull; Last Updated {{ formatDate .LastSeenAt }}</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>
+71 -12
View File
@@ -102,7 +102,11 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
</button>
<button id="tab-btn-targets" onclick="switchTab('targets')"
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
⚙️ Targets
🎯 Targets
</button>
<button id="tab-btn-preferences" onclick="switchTab('preferences')"
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
⚙️ Preferences
</button>
</div>
</div>
@@ -126,6 +130,11 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
{{ template "targetsTab" . }}
</div>
<!-- Tab: Preferences -->
<div id="tab-content-preferences" class="hidden space-y-5">
{{ template "preferencesTab" . }}
</div>
</main>
<!-- Right Slide-Over Detail Panel -->
@@ -230,19 +239,25 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
<script>
// ── Tab Routing ──────────────────────────────────────────────────────
const TABS = ['jobs', 'apply', 'targets'];
const TABS = ['jobs', 'apply', 'targets', 'preferences'];
function switchTab(tab) {
TABS.forEach(t => {
document.getElementById('tab-content-' + t).classList.add('hidden');
const content = document.getElementById('tab-content-' + t);
if (content) content.classList.add('hidden');
const btn = document.getElementById('tab-btn-' + t);
btn.classList.remove('text-indigo-400', 'border-indigo-500');
btn.classList.add('text-slate-400', 'border-transparent');
if (btn) {
btn.classList.remove('text-indigo-400', 'border-indigo-500');
btn.classList.add('text-slate-400', 'border-transparent');
}
});
document.getElementById('tab-content-' + tab).classList.remove('hidden');
const activeContent = document.getElementById('tab-content-' + tab);
if (activeContent) activeContent.classList.remove('hidden');
const activeBtn = document.getElementById('tab-btn-' + tab);
activeBtn.classList.remove('text-slate-400', 'border-transparent');
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
if (activeBtn) {
activeBtn.classList.remove('text-slate-400', 'border-transparent');
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
}
history.replaceState(null, '', '#' + tab);
}
@@ -308,6 +323,49 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
});
}
// ── Maintenance Actions ──────────────────────────────────────────────
function runMaintenanceAction(url, daysInputId, protectCheckboxId, btn, originalText) {
const days = document.getElementById(daysInputId)?.value || '14';
const protect = document.getElementById(protectCheckboxId)?.checked ?? true;
const alertBox = document.getElementById('maintenance-alert');
btn.disabled = true;
btn.textContent = 'Processing…';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ days: days, protect: protect ? 'true' : 'false' })
})
.then(res => res.json())
.then(data => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold ' +
(data.status === 'success' ? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300' : 'bg-rose-950/60 border-rose-500/40 text-rose-300');
alertBox.innerHTML = '<span>✓</span> ' + (data.message || 'Operation completed successfully.');
alertBox.classList.remove('hidden');
setTimeout(() => { alertBox.classList.add('hidden'); }, 8000);
}
// Refresh Jobs and Apply tables & stats
const jobsContainer = document.getElementById('jobs-container');
if (jobsContainer) htmx.trigger(jobsContainer, 'refresh');
const applyContainer = document.getElementById('apply-container');
if (applyContainer) htmx.trigger(applyContainer, 'refresh');
refreshStatus();
})
.catch(err => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold bg-rose-950/60 border-rose-500/40 text-rose-300';
alertBox.innerHTML = '<span>✕</span> Error: ' + err.message;
alertBox.classList.remove('hidden');
}
})
.finally(() => {
btn.disabled = false;
btn.textContent = originalText;
});
}
// ── Bottom Bar: Status Polling ────────────────────────────────────────
function refreshStatus() {
fetch('/api/status')
@@ -392,14 +450,15 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
table.querySelectorAll('tr[data-row]').forEach(row => {
const rowText = row.textContent.toLowerCase();
const rowQual = row.dataset.qual || ''; // qualified | not-qualified | pending
const rowApp = row.dataset.app || 'none'; // none | opted-out | pipeline
const rowApp = row.dataset.app || 'none'; // none | opted-out | expired | pipeline
const textMatch = !text || rowText.includes(text);
let qualMatch;
switch (qual) {
case 'default': qualMatch = rowApp !== 'opted-out' && rowApp !== 'pipeline' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowQual === 'not-qualified'; break;
case 'default': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowApp !== 'pipeline' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowQual === 'not-qualified'; break;
case 'saved': qualMatch = rowApp === 'pipeline'; break;
case 'all': qualMatch = rowApp !== 'opted-out'; break;
case 'all': qualMatch = true; break;
case 'expired': qualMatch = rowApp === 'expired'; break;
case 'opted-out': qualMatch = rowApp === 'opted-out'; break;
default: qualMatch = true;
}
+91
View File
@@ -0,0 +1,91 @@
package ui
const preferencesTabHTML = `{{ define "preferencesTab" }}
<div class="space-y-6 max-w-4xl">
<div>
<h2 class="text-base font-bold text-white flex items-center gap-2">
<span>⚙️</span> System Setup &amp; Maintenance
</h2>
<p class="text-xs text-slate-400 mt-0.5">Monitor database health and purge stale job postings not seen on target career sites</p>
</div>
<!-- Maintenance Result Alert Banner -->
<div id="maintenance-alert" class="hidden p-4 rounded-xl border transition-all"></div>
<!-- Statistics Overview Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">Total Ingested</span>
<span class="text-xl font-extrabold text-white mt-1 block">{{ .MaintenanceStats.total_jobs }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-emerald-400 block tracking-wider">Active (&le;7d)</span>
<span class="text-xl font-extrabold text-emerald-400 mt-1 block">{{ .MaintenanceStats.active_7d }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-amber-400 block tracking-wider">Stale (&gt;7d)</span>
<span class="text-xl font-extrabold text-amber-400 mt-1 block">{{ .MaintenanceStats.stale_7d }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-rose-400 block tracking-wider">Stale (&gt;30d)</span>
<span class="text-xl font-extrabold text-rose-400 mt-1 block">{{ .MaintenanceStats.stale_30d }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">Expired / Closed</span>
<span class="text-xl font-extrabold text-slate-300 mt-1 block">{{ .MaintenanceStats.expired_count }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-violet-400 block tracking-wider">In Pipeline</span>
<span class="text-xl font-extrabold text-violet-300 mt-1 block">{{ .MaintenanceStats.in_pipeline }}</span>
</div>
</div>
<!-- Maintenance Tool: Purge Stale Postings (Worker Queue Driven) -->
<div class="p-6 rounded-xl bg-slate-900/60 border border-slate-800 space-y-5">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<span class="w-9 h-9 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-lg">⚠️</span>
<div>
<h3 class="text-sm font-bold text-white">Purge Stale Postings (Mark Expired)</h3>
<p class="text-xs text-slate-400 mt-0.5">Enqueues worker queue tasks to mark job postings as <strong class="text-amber-300 font-semibold">Expired</strong> if they have not been discovered on target career boards for X days.</p>
</div>
</div>
</div>
<div class="pt-4 border-t border-slate-800/80 grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div>
<label for="expire-days" class="block text-xs font-semibold text-slate-300 mb-1.5">Not updated in:</label>
<select id="expire-days" class="w-full px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
<option value="7">7+ days</option>
<option value="14" selected>14+ days</option>
<option value="30">30+ days</option>
<option value="60">60+ days</option>
</select>
</div>
<div class="flex items-center gap-2 pb-2">
<input type="checkbox" id="expire-protect-pipeline" checked class="rounded bg-slate-800 border-slate-700 text-indigo-600 focus:ring-0">
<label for="expire-protect-pipeline" class="text-xs text-slate-300">Protect active applications</label>
</div>
<div>
<button type="button"
onclick="runMaintenanceAction('/api/maintenance/stale-expire', 'expire-days', 'expire-protect-pipeline', this, '⚠️ Queue Expire Tasks')"
class="w-full py-2 bg-amber-600 hover:bg-amber-500 text-xs font-semibold text-white rounded-lg shadow-md transition-all active:scale-95">
⚠️ Queue Expire Tasks
</button>
</div>
</div>
</div>
<!-- Explanation Card -->
<div class="p-5 rounded-xl bg-slate-900/40 border border-slate-800/80 text-xs text-slate-400 space-y-2">
<h4 class="font-bold text-slate-200 uppercase tracking-wider text-[11px]">💡 How Job Verification Works</h4>
<p>
Every time <strong class="text-indigo-300">🔍 Discover</strong> runs, Hireme searches live listings directly on career sites (Google, Apple, Meta).
When an existing job appears in search results, its <strong class="text-emerald-400">Last Updated</strong> timestamp is refreshed.
If a role closes or is removed from search results, its Last Updated timestamp remains unchanged, allowing you to safely purge expired postings using the tool above.
</p>
</div>
</div>
{{ end }}`
+7
View File
@@ -72,6 +72,7 @@ type PageData struct {
Targets []db.SearchTarget
Jobs []db.JobWithAnalysis
Applications []db.JobWithAnalysis
MaintenanceStats map[string]int
ActiveTab string
LLMProvider string
ToReviewCount int
@@ -92,6 +93,7 @@ func init() {
jobDetailHTML,
targetsTabHTML,
targetListHTML,
preferencesTabHTML,
applyTabHTML,
applyTableHTML,
kanbanBoardHTML,
@@ -131,3 +133,8 @@ func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error {
func RenderApplyTable(w io.Writer, apps []db.JobWithAnalysis) error {
return rootTemplate.ExecuteTemplate(w, "applyTable", apps)
}
func RenderPreferencesTab(w io.Writer, data PageData) error {
return rootTemplate.ExecuteTemplate(w, "preferencesTab", data)
}