diff --git a/data/config/targets.yaml b/data/config/targets.yaml index 035f054..d3018f4 100644 --- a/data/config/targets.yaml +++ b/data/config/targets.yaml @@ -2,8 +2,4 @@ targets: - name: "Google Software Engineer (Seattle, MID Level)" target_url: "https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID" platform: "google_jobs" - is_active: true - - name: "DeepMind" - target_url: "https://www.google.com/about/careers/applications/jobs/results?company=DeepMind" - platform: "google_jobs" - is_active: true + is_active: true \ No newline at end of file diff --git a/go.mod b/go.mod index ecc14e4..a95fd04 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,17 @@ module github.com/hireme/hireme go 1.26 require ( + github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 github.com/mattn/go-sqlite3 v1.14.42 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 - golang.org/x/net v0.52.0 + go.uber.org/zap v1.28.0 + golang.org/x/net v0.55.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/JohannesKaufmann/dom v0.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.28.0 // indirect ) diff --git a/go.sum b/go.sum index dc4bf09..5debd4c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= +github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -14,6 +18,8 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 1c19392..f3e99df 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -7,6 +7,32 @@ import ( "log" ) +// QueueStats holds task queue counters +type QueueStats struct { + Pending int `json:"pending"` + Running int `json:"running"` + Failed int `json:"failed"` + Completed int `json:"completed"` +} + +// GetQueueStats returns counts for each task status +func (db *DB) GetQueueStats() (*QueueStats, error) { + query := ` + SELECT + COALESCE(SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = 'RUNNING' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END), 0) + FROM tasks + ` + var stats QueueStats + err := db.QueryRow(query).Scan(&stats.Pending, &stats.Running, &stats.Failed, &stats.Completed) + if err != nil { + return nil, err + } + return &stats, nil +} + // EnqueueTask adds a new task to the queue func (db *DB) EnqueueTask(taskType string, payload interface{}) error { payloadBytes, err := json.Marshal(payload) diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 0255c2a..6d15d2d 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -1,9 +1,9 @@ package ui import ( + "encoding/json" "net/http" "strconv" - "strings" "github.com/hireme/hireme/internal/config" "github.com/hireme/hireme/internal/db" @@ -33,12 +33,15 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /jobs", s.handleGetJobs) mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail) mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob) + mux.HandleFunc("POST /jobs/{id}/opt-out", s.handleOptOut) + mux.HandleFunc("GET /applications", s.handleGetApplications) mux.HandleFunc("POST /applications", s.handleCreateApplication) mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus) mux.HandleFunc("POST /api/tasks/discover", s.handleDiscoverTasks) mux.HandleFunc("POST /api/tasks/clean", s.handleCleanTasks) mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs) mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks) + mux.HandleFunc("GET /api/status", s.handleGetStatus) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -189,6 +192,39 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`Analysis Queued`)) } +func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "invalid job id", http.StatusBadRequest) + return + } + + _, err = s.db.CreateOrUpdateApplication(id, "OPTED_OUT", "") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + jobs, err := s.db.GetJobsWithAnalysis() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = RenderJobList(w, jobs) +} + +func (s *Server) handleGetApplications(w http.ResponseWriter, r *http.Request) { + apps, err := s.db.GetApplications() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = RenderApplyTable(w, apps) +} + func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -213,13 +249,7 @@ func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) return } - if strings.Contains(r.Header.Get("HX-Target"), "kanban") { - apps, _ := s.db.GetApplications() - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = RenderKanbanBoard(w, apps) - return - } - + // Always return the updated job list so the action column refreshes s.handleGetJobs(w, r) } @@ -270,7 +300,7 @@ func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) { updatedApps, _ := s.db.GetApplications() w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = RenderKanbanBoard(w, updatedApps) + _ = RenderApplyTable(w, updatedApps) } func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) { @@ -295,7 +325,7 @@ func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) { count++ } } - + w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "success", "message": "Queued ` + strconv.Itoa(count) + ` discovery tasks"}`)) } @@ -332,3 +362,26 @@ func (s *Server) handleToggleTasks(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "` + status + `"}`)) } + +// handleGetStatus returns a JSON snapshot of worker pool state and task queue counts. +// Used by the bottom bar to auto-refresh every few seconds without a full page reload. +func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) { + workerStatus := "paused" + if s.pool.IsRunning() { + workerStatus = "running" + } + + stats, err := s.db.GetQueueStats() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + resp := map[string]interface{}{ + "worker_status": workerStatus, + "queue": stats, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/internal/ui/template_apply.go b/internal/ui/template_apply.go new file mode 100644 index 0000000..49da52b --- /dev/null +++ b/internal/ui/template_apply.go @@ -0,0 +1,139 @@ +package ui + +const applyTabHTML = `{{ define "applyTab" }} +
Track all saved and in-progress applications
+| Job & Company | +Pipeline Stage | +Score | +Link | +Last Updated | +Change Status | +
|---|---|---|---|---|---|
| + + {{ .Company }} + π {{ .Location }} + | + + +
+
+ Saved
+ βΆ
+ Applied
+ βΆ
+ Interview
+ βΆ
+ {{ if eq .App.Status "OFFER" }}
+ Offer π
+ {{ else if eq .App.Status "REJECTED" }}
+ Rejected
+ {{ else }}
+ Offer
+ {{ end }}
+
+ |
+
+
+ + {{ if .Analysis }} + + {{ printf "%.0f" .Analysis.Score }} + + {{ else }} + β + {{ end }} + | + + ++ + π + + | + + ++ {{ formatDate .App.UpdatedAt }} + | + + ++ + | +
|
+ π +No saved applications yet. Go to Jobs tab and click + Save on any job. + |
+ |||||
All ingested postings Β· AI-scored Β· sortable
+| Job Title & Company | +Link | +Analysis | +Score & Qualification | +Reason / Tags | +Crawled | +Actions | +
|---|---|---|---|---|---|---|
|
+
+
+ {{ .Company }}
+ •
+ π {{ .Location }}
+
+ |
+
+
+ + + π + + | + + ++ {{ if .Analysis }} + {{ if eq .Analysis.Status "PARSED" }} + PARSED + {{ else if eq .Analysis.Status "PENDING" }} + PENDING + {{ else if eq .Analysis.Status "DONE" }} + DONE + {{ else }} + {{ .Analysis.Status }} + {{ end }} + {{ else }} + UNANALYZED + {{ end }} + | + + ++ {{ if .Analysis }} + {{ if .Analysis.IsQualified }} + + β {{ printf "%.0f" .Analysis.Score }} + + {{ else }} + + β Not Qualified + + {{ end }} + {{ else }} + β + {{ end }} + | + + +
+ {{ if .App }}{{ if eq .App.Status "OPTED_OUT" }}
+ Opted out
+ {{ end }}{{ end }}
+ {{ if .Analysis }}
+ {{ if .Analysis.IsQualified }}
+
+ {{ range parseJSON .Analysis.MatchedHighlights }}
+ {{ . }}
+ {{ else }}
+ No tags
+ {{ end }}
+
+ {{ else }}
+ {{ $missing := parseJSON .Analysis.MissingHighlights }}
+ {{ if $missing }}
+ {{ index $missing 0 }}
+ {{ end }}
+ {{ end }}
+ {{ end }}
+ |
+
+
+ + {{ formatDate .CrawledAt }} + | + + +
+ {{ if .App }}
+ {{ if eq .App.Status "OPTED_OUT" }}
+
+
+ {{ else }}
+
+
+ {{ .App.Status }}
+
+ {{ end }}
+ {{ else }}
+
+
+
+
+
+ {{ end }}
+ |
+
|
+ π +No job postings found. Click π Discover in the bottom bar to start crawling. + |
+ ||||||
π {{ .Location }} • Ingested {{ formatDate .CrawledAt }}
+Job Command Center
+Configure job board URLs to crawl for new postings
+| Platform | +Name | +Target URL | +Last Crawled | +Status | +Action | +
|---|---|---|---|---|---|
| {{ platformIcon .Platform }} | +{{ .Name }} | ++ {{ .TargetURL }} + | +{{ formatTime .LastCrawledAt }} | ++ {{ if .IsActive }} + Active + {{ else }} + Paused + {{ end }} + | ++ + | +
|
+ π― +No search targets configured yet. Add one above. + |
+ |||||
Job Application Command Center
-Total Search Targets
-Qualified Jobs Matched
-Total Ingested Postings
-Active Applications
-Ranked by rule score + cached LLM skill gap analysis
-| Platform | -Name | -Target URL | -Last Crawled | -Status | -Action | -
|---|---|---|---|---|---|
| {{ platformIcon .Platform }} | -{{ .Name }} | -- {{ .TargetURL }} - | -{{ formatTime .LastCrawledAt }} | -- {{ if .IsActive }} - Active - {{ else }} - Paused - {{ end }} - | -- - | -
| No search targets configured yet. | -|||||
| Job Title & Company | -Original Link | -Analysis Status | -Score & Qualification | -Keyword Tags | -Crawled Date | -Action | -
|---|---|---|---|---|---|---|
|
-
-
- {{ .Company }}
- •
- π {{ .Location }}
-
- |
-
-
- - - π - - | - - -- {{ if .Analysis }} - {{ if eq .Analysis.Status "PARSED" }} - - PARSED - - {{ else if eq .Analysis.Status "FILTERED_OUT" }} - - FILTERED_OUT - - {{ else }} - - {{ .Analysis.Status }} - - {{ end }} - {{ else }} - - UNANALYZED - - {{ end }} - | - - -
- {{ if .Analysis }}
- {{ if .Analysis.IsQualified }}
-
- β
{{ printf "%.0f" .Analysis.Score }} Qualified
-
- {{ else }}
-
-
- Not Qualified
-
- {{ $missing := parseJSON .Analysis.MissingHighlights }}
- {{ if $missing }}
- {{ index $missing 0 }}
- {{ end }}
-
- {{ end }}
- {{ else }}
- -
- {{ end }}
- |
-
-
-
- {{ if .Analysis }}
-
- {{ range parseJSON .Analysis.MatchedHighlights }}
-
- {{ . }}
-
- {{ else }}
- No tags
- {{ end }}
-
- {{ else }}
- No tags
- {{ end }}
- |
-
-
- - {{ formatDate .CrawledAt }} - | - - -- {{ if .App }} - - {{ .App.Status }} - - {{ else }} - - {{ end }} - | -
| - No job postings found yet. Click π Discover Jobs to enqueue crawler tasks. - | -||||||
π {{ .Location }} • Ingested {{ formatDate .CrawledAt }}
-