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
+35 -3
View File
@@ -55,7 +55,8 @@ func (db *DB) migrate() error {
location TEXT,
apply_url TEXT NOT NULL,
raw_content TEXT NOT NULL,
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS job_analyses (
@@ -92,6 +93,37 @@ func (db *DB) migrate() error {
);
`
_, err := db.Exec(schema)
return err
if _, err := db.Exec(schema); err != nil {
return err
}
// Dynamic migration for existing databases: check if last_seen_at exists in raw_jobs
var hasLastSeen bool
rows, err := db.Query(`PRAGMA table_info(raw_jobs)`)
if err == nil {
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dfltValue sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err == nil {
if name == "last_seen_at" {
hasLastSeen = true
break
}
}
}
_ = rows.Close()
}
if !hasLastSeen {
if _, err := db.Exec(`ALTER TABLE raw_jobs ADD COLUMN last_seen_at DATETIME`); err != nil {
logger.Log.Warnw("Failed to add last_seen_at column", "component", "DB", "error", err)
} else {
_, _ = db.Exec(`UPDATE raw_jobs SET last_seen_at = crawled_at WHERE last_seen_at IS NULL`)
_, _ = db.Exec(`UPDATE raw_jobs SET last_seen_at = CURRENT_TIMESTAMP WHERE last_seen_at IS NULL`)
}
}
return nil
}
+1
View File
@@ -23,6 +23,7 @@ type RawJob struct {
ApplyURL string `json:"apply_url"`
RawContent string `json:"raw_content"`
CrawledAt time.Time `json:"crawled_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type Task struct {
+131 -12
View File
@@ -185,15 +185,15 @@ func (db *DB) InsertRawJob(canonicalID string, targetID *int64, title, company,
}
query := `
INSERT INTO raw_jobs (canonical_id, target_id, title, company, location, apply_url, raw_content)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO raw_jobs (canonical_id, target_id, title, company, location, apply_url, raw_content, crawled_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(canonical_id) DO NOTHING
RETURNING id, canonical_id, target_id, title, company, location, apply_url, raw_content, crawled_at
RETURNING id, canonical_id, target_id, title, company, location, apply_url, raw_content, crawled_at, last_seen_at
`
var job RawJob
var tID sql.NullInt64
err = db.QueryRow(query, canonicalID, targetID, title, company, location, applyURL, rawContent).Scan(
&job.ID, &job.CanonicalID, &tID, &job.Title, &job.Company, &job.Location, &job.ApplyURL, &job.RawContent, &job.CrawledAt,
&job.ID, &job.CanonicalID, &tID, &job.Title, &job.Company, &job.Location, &job.ApplyURL, &job.RawContent, &job.CrawledAt, &job.LastSeenAt,
)
if err == sql.ErrNoRows {
return nil, false, nil // Duplicate job
@@ -207,6 +207,125 @@ func (db *DB) InsertRawJob(canonicalID string, targetID *int64, title, company,
return &job, true, nil
}
func (db *DB) TouchJobLastSeen(canonicalID string) error {
query := `UPDATE raw_jobs SET last_seen_at = CURRENT_TIMESTAMP WHERE canonical_id = ?`
_, err := db.Exec(query, canonicalID)
return err
}
func (db *DB) TouchJobLastSeenByID(rawJobID int64) error {
query := `UPDATE raw_jobs SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`
_, err := db.Exec(query, rawJobID)
return err
}
func (db *DB) ExpireJob(rawJobID int64, reason string) error {
if reason == "" {
reason = "Marked expired"
}
query := `
INSERT INTO job_applications (raw_job_id, status, notes, updated_at)
VALUES (?, 'EXPIRED', ?, CURRENT_TIMESTAMP)
ON CONFLICT(raw_job_id) DO UPDATE SET
status='EXPIRED',
notes=COALESCE(job_applications.notes || ' | ', '') || excluded.notes,
updated_at=CURRENT_TIMESTAMP
`
_, err := db.Exec(query, rawJobID, reason)
return err
}
func (db *DB) EnqueueExpireTasks(days int, preserveActivePipeline bool) (int, error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
var query string
if preserveActivePipeline {
query = `
SELECT r.id FROM raw_jobs r
LEFT JOIN job_applications app ON r.id = app.raw_job_id
WHERE r.last_seen_at < datetime('now', '-' || ? || ' days')
AND (app.status IS NULL OR app.status NOT IN ('APPLIED', 'INTERVIEW', 'OFFER', 'EXPIRED', 'OPTED_OUT'))
`
} else {
query = `
SELECT r.id FROM raw_jobs r
LEFT JOIN job_applications app ON r.id = app.raw_job_id
WHERE r.last_seen_at < datetime('now', '-' || ? || ' days')
AND (app.status IS NULL OR app.status NOT IN ('EXPIRED', 'OPTED_OUT'))
`
}
rows, err := tx.Query(query, days)
if err != nil {
return 0, err
}
var rawJobIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return 0, err
}
rawJobIDs = append(rawJobIDs, id)
}
_ = rows.Close()
for _, id := range rawJobIDs {
payloadJSON := fmt.Sprintf(`{"raw_job_id":%d,"reason":"Direct expire: not seen on target for %d+ days"}`, id, days)
if _, err := tx.Exec(`INSERT INTO tasks (task_type, payload, status) VALUES ('EXPIRE_JOB', ?, 'PENDING')`, payloadJSON); err != nil {
return 0, err
}
}
return len(rawJobIDs), tx.Commit()
}
func (db *DB) GetJobMaintenanceStats() (map[string]int, error) {
stats := make(map[string]int)
// Total raw jobs
var totalJobs int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs`).Scan(&totalJobs)
stats["total_jobs"] = totalJobs
// Active <= 7 days
var active7d int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs WHERE last_seen_at >= datetime('now', '-7 days')`).Scan(&active7d)
stats["active_7d"] = active7d
// Stale > 7 days
var stale7d int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs WHERE last_seen_at < datetime('now', '-7 days')`).Scan(&stale7d)
stats["stale_7d"] = stale7d
// Stale > 30 days
var stale30d int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs WHERE last_seen_at < datetime('now', '-30 days')`).Scan(&stale30d)
stats["stale_30d"] = stale30d
// Expired count
var expiredCount int
_ = db.QueryRow(`SELECT COUNT(*) FROM job_applications WHERE status = 'EXPIRED'`).Scan(&expiredCount)
stats["expired_count"] = expiredCount
// Opted out count
var optedOutCount int
_ = db.QueryRow(`SELECT COUNT(*) FROM job_applications WHERE status = 'OPTED_OUT'`).Scan(&optedOutCount)
stats["opted_out_count"] = optedOutCount
// In pipeline count
var inPipeline int
_ = db.QueryRow(`SELECT COUNT(*) FROM job_applications WHERE status IN ('SAVED', 'APPLIED', 'INTERVIEW', 'OFFER')`).Scan(&inPipeline)
stats["in_pipeline"] = inPipeline
return stats, nil
}
func (db *DB) CanonicalIDExists(canonicalID string) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM raw_jobs WHERE canonical_id = ?)`
var exists bool
@@ -330,7 +449,7 @@ func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, matchedJSON, missingJSON, sug
func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at
FROM raw_jobs r
JOIN job_analyses a ON r.id = a.raw_job_id
@@ -353,7 +472,7 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
var analyzedAt sql.NullTime
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
)
if err != nil {
@@ -383,7 +502,7 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r
@@ -411,7 +530,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
var updatedAt sql.NullTime
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
@@ -457,7 +576,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r
@@ -478,7 +597,7 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
var updatedAt sql.NullTime
err := row.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
@@ -554,7 +673,7 @@ func (db *DB) CreateOrUpdateApplication(rawJobID int64, status, notes string) (*
func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM job_applications app
@@ -582,7 +701,7 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
var updatedAt time.Time
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
+115
View File
@@ -182,3 +182,118 @@ func TestRemoveTargetFromYAML(t *testing.T) {
}
}
func TestTouchJobLastSeenAndMaintenance(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_maintenance.db")
database, err := InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
// Insert jobs
job1, _, err := database.InsertRawJob("canon-1", nil, "Job 1", "Google", "Seattle", "https://google.com/1", "Software Engineer Go")
if err != nil || job1.ID == 0 {
t.Fatalf("failed to insert job 1: %v", err)
}
job2, _, err := database.InsertRawJob("canon-2", nil, "Job 2", "Apple", "Seattle", "https://apple.com/2", "Software Engineer Swift")
if err != nil {
t.Fatalf("failed to insert job 2: %v", err)
}
job3, _, err := database.InsertRawJob("canon-3", nil, "Job 3", "Meta", "Seattle", "https://meta.com/3", "Software Engineer React")
if err != nil {
t.Fatalf("failed to insert job 3: %v", err)
}
// 1. Touch last seen for canon-1
if err := database.TouchJobLastSeen("canon-1"); err != nil {
t.Fatalf("TouchJobLastSeen failed: %v", err)
}
// Make job2 and job3 artificially stale (20 days and 40 days old)
_, err = database.Exec(`UPDATE raw_jobs SET last_seen_at = datetime('now', '-20 days') WHERE id = ?`, job2.ID)
if err != nil {
t.Fatalf("failed to update last_seen_at: %v", err)
}
_, err = database.Exec(`UPDATE raw_jobs SET last_seen_at = datetime('now', '-40 days') WHERE id = ?`, job3.ID)
if err != nil {
t.Fatalf("failed to update last_seen_at: %v", err)
}
// Protect job3 in active pipeline (APPLIED)
_, _ = database.CreateOrUpdateApplication(job3.ID, "APPLIED", "Applied to Meta")
// 2. Enqueue expire tasks (> 14 days) with pipeline protection
queuedExpireCount, err := database.EnqueueExpireTasks(14, true)
if err != nil {
t.Fatalf("EnqueueExpireTasks failed: %v", err)
}
// job2 is stale and unprotected -> should be queued
// job3 is stale but protected (APPLIED) -> should NOT be queued
// job1 is fresh -> should NOT be queued
if queuedExpireCount != 1 {
t.Errorf("expected 1 job queued for expire, got %d", queuedExpireCount)
}
// 3. Directly expire job2
if err := database.ExpireJob(job2.ID, "Test expiration"); err != nil {
t.Fatalf("ExpireJob failed: %v", err)
}
// Verify job2 status in DB
j2Detail, err := database.GetJobByID(job2.ID)
if err != nil {
t.Fatalf("GetJobByID failed: %v", err)
}
if j2Detail.App == nil || j2Detail.App.Status != "EXPIRED" {
t.Errorf("expected job2 to have EXPIRED application, got %v", j2Detail.App)
}
// Verify job3 still APPLIED
j3Detail, err := database.GetJobByID(job3.ID)
if err != nil {
t.Fatalf("GetJobByID failed: %v", err)
}
if j3Detail.App == nil || j3Detail.App.Status != "APPLIED" {
t.Errorf("expected job3 to remain APPLIED, got %v", j3Detail.App)
}
// 4. Stats check
stats, err := database.GetJobMaintenanceStats()
if err != nil {
t.Fatalf("GetJobMaintenanceStats failed: %v", err)
}
if stats["total_jobs"] != 3 {
t.Errorf("expected total_jobs=3, got %d", stats["total_jobs"])
}
if stats["active_7d"] != 1 {
t.Errorf("expected active_7d=1, got %d", stats["active_7d"])
}
if stats["stale_7d"] != 2 {
t.Errorf("expected stale_7d=2, got %d", stats["stale_7d"])
}
if stats["expired_count"] != 1 {
t.Errorf("expected expired_count=1, got %d", stats["expired_count"])
}
}
func TestDevDBMigration(t *testing.T) {
devDBPath := "../../data/db/dev/hireme.db"
if _, err := os.Stat(devDBPath); os.IsNotExist(err) {
return
}
database, err := InitDB(devDBPath)
if err != nil {
t.Fatalf("InitDB dev db failed: %v", err)
}
defer func() { _ = database.Close() }()
jobs, err := database.GetJobsWithAnalysis()
if err != nil {
t.Fatalf("GetJobsWithAnalysis on dev db failed: %v", err)
}
t.Logf("Successfully verified %d jobs from dev db", len(jobs))
}
+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)
}
+34 -13
View File
@@ -32,7 +32,6 @@ type WorkerPool struct {
}
func NewWorkerPool(database *db.DB, analyzer analyzer.LLMAnalyzer, numWorkers int, filterCfg *config.FilterConfig) *WorkerPool {
// Initialize filters using the provided config
filterPipe := filter.NewPipeline(
filter.NewHardKeywordFilter(filterCfg.HardKeywords),
filter.NewScoringFilter(filterCfg.ScoringWeights),
@@ -54,33 +53,37 @@ func (p *WorkerPool) Toggle() bool {
if p.IsRunning() {
p.Stop()
return false
} else {
p.Start()
return true
}
p.Start()
return true
}
func (p *WorkerPool) Start() {
if p.isRunning.Load() {
return
if p.isRunning.Swap(true) {
return // Already running
}
p.ctx, p.cancel = context.WithCancel(context.Background())
p.isRunning.Store(true)
logger.Log.Infow("Started workers", "component", "WorkerPool", "count", p.workerCount)
for i := 0; i < p.workerCount; i++ {
p.wg.Add(1)
go p.workerLoop(i)
}
logger.Log.Infow("Started workers", "component", "WorkerPool", "count", p.workerCount)
}
func (p *WorkerPool) Stop() {
if !p.isRunning.Load() {
return
if !p.isRunning.Swap(false) {
return // Already stopped
}
logger.Log.Infow("Stopping worker pool...", "component", "WorkerPool")
if p.cancel != nil {
p.cancel()
}
p.cancel()
p.isRunning.Store(false)
p.wg.Wait()
logger.Log.Infow("All workers stopped", "component", "WorkerPool")
logger.Log.Infow("Worker pool stopped", "component", "WorkerPool")
}
func (p *WorkerPool) workerLoop(workerID int) {
@@ -95,7 +98,7 @@ func (p *WorkerPool) workerLoop(workerID int) {
default:
task, err := p.db.ClaimNextTask()
if err != nil {
logger.Log.Errorw("Error claiming task", "component", "Worker", "worker_id", workerID, "error", err)
logger.Log.Errorw("Failed to claim task", "component", "Worker", "worker_id", workerID, "error", err)
time.Sleep(1 * time.Second)
continue
}
@@ -119,6 +122,8 @@ func (p *WorkerPool) workerLoop(workerID int) {
processErr = p.handleProcess(p.ctx, task)
case "ANALYZE":
processErr = p.handleAnalyze(p.ctx, task)
case "EXPIRE_JOB":
processErr = p.handleExpireJob(p.ctx, task)
default:
processErr = fmt.Errorf("unknown task type: %s", task.TaskType)
}
@@ -159,6 +164,11 @@ type AnalyzePayload struct {
RawJobID int64 `json:"raw_job_id"`
}
type ExpireJobPayload struct {
RawJobID int64 `json:"raw_job_id"`
Reason string `json:"reason"`
}
// ---------------------------------------------------------
// Task Handlers
// ---------------------------------------------------------
@@ -179,6 +189,8 @@ func (p *WorkerPool) handleDiscover(ctx context.Context, task *db.Task) error {
// Check deduplication (already fetched/inserted jobs)
exists, err := p.db.CanonicalIDExists(disc.CanonicalID)
if err == nil && exists {
// Update last seen timestamp to confirm job is still active on target board
_ = p.db.TouchJobLastSeen(disc.CanonicalID)
continue // Skip existing job
}
@@ -289,6 +301,15 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error {
return err
}
func (p *WorkerPool) handleExpireJob(ctx context.Context, task *db.Task) error {
var payload ExpireJobPayload
if err := json.Unmarshal([]byte(task.Payload), &payload); err != nil {
return err
}
logger.Log.Infow("Expiring job", "component", "Worker", "raw_job_id", payload.RawJobID, "reason", payload.Reason)
return p.db.ExpireJob(payload.RawJobID, payload.Reason)
}
func (p *WorkerPool) getCrawlerForPlatform(platform string) crawler.SiteCrawler {
plat := strings.ToLower(platform)
if strings.Contains(plat, "google") {