init
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s

This commit is contained in:
redlineo committed 2026-07-13 10:14:00 +03:00
commit d295c7f139
1527 files changed
+270726

No files matched your search

+918
View File
@@ -0,0 +1,918 @@
package data
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/santifer/career-ops/dashboard/internal/model"
)
var (
reReportLink = regexp.MustCompile(`\[(\d+)\]\(([^)]+)\)`)
reScoreValue = regexp.MustCompile(`(\d+\.?\d*)/5`)
reArchetype = regexp.MustCompile(`(?i)\*\*(?:Arquetipo|Archetype)(?:\s+(?:detectado|detected))?\*\*\s*\|\s*(.+)`)
reTlDr = regexp.MustCompile(`(?i)\*\*TL;DR\*\*\s*\|\s*(.+)`)
reTlDrColon = regexp.MustCompile(`(?i)\*\*TL;DR:\*\*\s*(.+)`)
reRemote = regexp.MustCompile(`(?i)\*\*Remote\*\*\s*\|\s*(.+)`)
reComp = regexp.MustCompile(`(?i)\*\*Comp\*\*\s*\|\s*(.+)`)
reArchetypeColon = regexp.MustCompile(`(?i)\*\*(?:Arquetipo|Archetype):\*\*\s*(.+)`)
reArchetypeYAML = regexp.MustCompile(`(?m)^archetype:\s*"?([^"\n]+)"?\s*$`)
reReportURL = regexp.MustCompile(`(?m)^\*\*URL:\*\*\s*(https?://\S+)`)
reBatchID = regexp.MustCompile(`(?m)^\*\*Batch ID:\*\*\s*(\d+)`)
)
// resolveReportPath converts a report link from the tracker into a path
// relative to careerOpsPath. Links are normally relative to the tracker
// file's own directory (see merge-tracker.mjs link normalization, #760);
// legacy trackers may still carry root-relative links, so fall back to the
// raw link when the tracker-relative resolution does not exist on disk.
func resolveReportPath(careerOpsPath, trackerPath, link string) string {
resolved := filepath.Join(filepath.Dir(trackerPath), link)
if _, err := os.Stat(resolved); err != nil {
legacy := filepath.Join(careerOpsPath, link)
if _, err2 := os.Stat(legacy); err2 == nil {
resolved = legacy
}
}
if rel, err := filepath.Rel(careerOpsPath, resolved); err == nil {
return rel
}
return link
}
// ParseApplications reads applications.md and returns parsed applications.
// It tries both {path}/applications.md and {path}/data/applications.md for compatibility.
func ParseApplications(careerOpsPath string) []model.CareerApplication {
filePath := filepath.Join(careerOpsPath, "applications.md")
content, err := os.ReadFile(filePath)
if err != nil {
// Fallback: try data/ subdirectory
filePath = filepath.Join(careerOpsPath, "data", "applications.md")
content, err = os.ReadFile(filePath)
if err != nil {
return nil
}
}
lines := strings.Split(string(content), "\n")
apps := make([]model.CareerApplication, 0)
num := 0
// Map columns by header name rather than fixed position, so a customized or
// reordered tracker (e.g. an inserted Location column) does not desync the
// reader. Falls back to the legacy fixed layout when no header is present.
// This matches the Node tracker tooling, which became header-aware in #954.
cols := resolveTrackerColumns(lines)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "# ") || strings.HasPrefix(line, "|---") || strings.HasPrefix(line, "| #") {
continue
}
if !strings.HasPrefix(line, "|") {
continue
}
fields := splitTrackerRow(line)
if len(fields) < 8 {
continue
}
at := func(name string) string {
if idx, ok := cols[name]; ok && idx >= 0 && idx < len(fields) {
return fields[idx]
}
return ""
}
num++
trackerNumber := num
if parsedNumber, err := strconv.Atoi(at("num")); err == nil {
trackerNumber = parsedNumber
}
app := model.CareerApplication{
Number: trackerNumber,
Date: at("date"),
Company: at("company"),
Role: at("role"),
Status: at("status"),
HasPDF: strings.Contains(at("pdf"), "\u2705"),
}
// Parse score from the Score column.
app.ScoreRaw = at("score")
if sm := reScoreValue.FindStringSubmatch(at("score")); sm != nil {
app.Score, _ = strconv.ParseFloat(sm[1], 64)
}
// Parse report link. Tracker links are written relative to the
// tracker file itself (e.g. ../reports/... when the tracker lives in
// data/), so resolve against the tracker's directory and normalize
// back to a careerOpsPath-relative path, which is what every
// consumer joins against. Legacy root-relative links are kept as a
// fallback when the resolved file does not exist.
if rm := reReportLink.FindStringSubmatch(at("report")); rm != nil {
app.ReportNumber = rm[1]
app.ReportPath = resolveReportPath(careerOpsPath, filePath, rm[2])
}
// Notes column, when present.
app.Notes = at("notes")
// Lift location / work mode / pay / last-contact out of the notes free-text
deriveNoteFields(&app)
apps = append(apps, app)
}
// Enrich with job URLs using 5-tier strategy:
// 1. **URL:** field in report header (newest reports)
// 2. **Batch ID:** in report -> batch-input.tsv URL lookup
// 3. report_num -> batch-state completed mapping (legacy)
// 4. scan-history.tsv (pipeline scan entries matched by company+role)
// 5. company name fallback from batch-input.tsv
batchURLs := loadBatchInputURLs(careerOpsPath)
reportNumURLs := loadJobURLs(careerOpsPath)
for i := range apps {
if apps[i].ReportPath == "" {
continue
}
fullReport := filepath.Join(careerOpsPath, apps[i].ReportPath)
reportContent, err := os.ReadFile(fullReport)
if err != nil {
continue
}
header := string(reportContent)
// Only scan the header (first 1000 bytes) for speed
if len(header) > 1000 {
header = header[:1000]
}
// Strategy 1: **URL:** in report
if m := reReportURL.FindStringSubmatch(header); m != nil {
apps[i].JobURL = m[1]
continue
}
// Strategy 2: **Batch ID:** -> batch-input.tsv
if m := reBatchID.FindStringSubmatch(header); m != nil {
if url, ok := batchURLs[m[1]]; ok {
apps[i].JobURL = url
continue
}
}
// Strategy 3: report_num -> batch-state completed mapping
if reportNumURLs != nil {
if url, ok := reportNumURLs[apps[i].ReportNumber]; ok {
apps[i].JobURL = url
continue
}
}
}
// Strategy 4: scan-history.tsv (pipeline scan entries matched by company+role)
enrichFromScanHistory(careerOpsPath, apps)
// Strategy 5: company name fallback from batch-input.tsv
enrichAppURLsByCompany(careerOpsPath, apps)
return apps
}
// loadBatchInputURLs reads batch-input.tsv and returns a map of batch ID -> job URL.
func loadBatchInputURLs(careerOpsPath string) map[string]string {
inputPath := filepath.Join(careerOpsPath, "batch", "batch-input.tsv")
inputData, err := os.ReadFile(inputPath)
if err != nil {
return nil
}
result := make(map[string]string)
for _, line := range strings.Split(string(inputData), "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 4 || fields[0] == "id" {
continue
}
id := fields[0]
notes := fields[3]
// Extract real job URL from notes: "Title @ Company | Match% | https://actual-url"
if idx := strings.LastIndex(notes, "| "); idx >= 0 {
u := strings.TrimSpace(notes[idx+2:])
if strings.HasPrefix(u, "http") {
result[id] = u
continue
}
}
// Fallback: use JackJill URL
if strings.HasPrefix(fields[1], "http") {
result[id] = fields[1]
}
}
return result
}
// batchEntry holds parsed data from batch-input.tsv.
type batchEntry struct {
id string
url string
company string
role string
}
// loadJobURLs reads batch TSV files and returns a map of report_num -> job URL.
// Uses two strategies: (1) report_num mapping for completed jobs, (2) company name
// matching as fallback for failed/missing jobs.
func loadJobURLs(careerOpsPath string) map[string]string {
// Read batch-input.tsv: id \t url \t source \t notes
inputPath := filepath.Join(careerOpsPath, "batch", "batch-input.tsv")
inputData, err := os.ReadFile(inputPath)
if err != nil {
return nil
}
// Parse batch-input: extract job URL, company, and role from notes
entries := make(map[string]batchEntry) // keyed by id
for _, line := range strings.Split(string(inputData), "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 4 || fields[0] == "id" {
continue
}
e := batchEntry{id: fields[0]}
notes := fields[3]
// Extract URL from notes: "Title @ Company | Match% | https://actual-url"
if idx := strings.LastIndex(notes, "| "); idx >= 0 {
u := strings.TrimSpace(notes[idx+2:])
if strings.HasPrefix(u, "http") {
e.url = u
}
}
// Fallback: use JackJill URL from field 1
if e.url == "" && strings.HasPrefix(fields[1], "http") {
e.url = fields[1]
}
// Extract company and role: "Role @ Company | Match% | URL"
notesPart := notes
if pipeIdx := strings.Index(notesPart, " | "); pipeIdx >= 0 {
notesPart = notesPart[:pipeIdx]
}
if atIdx := strings.LastIndex(notesPart, " @ "); atIdx >= 0 {
e.role = strings.TrimSpace(notesPart[:atIdx])
e.company = strings.TrimSpace(notesPart[atIdx+3:])
}
if e.url != "" {
entries[fields[0]] = e
}
}
// Read batch-state.tsv: id \t url \t status \t ... \t report_num \t ...
statePath := filepath.Join(careerOpsPath, "batch", "batch-state.tsv")
stateData, err := os.ReadFile(statePath)
if err != nil {
return nil
}
// Strategy 1: map report_num -> URL only for COMPLETED jobs
reportToURL := make(map[string]string)
for _, line := range strings.Split(string(stateData), "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 6 || fields[0] == "id" {
continue
}
id := fields[0]
status := fields[2]
reportNum := fields[5]
if status != "completed" || reportNum == "" || reportNum == "-" {
continue
}
if e, ok := entries[id]; ok {
reportToURL[reportNum] = e.url
if len(reportNum) < 3 {
reportToURL[fmt.Sprintf("%03s", reportNum)] = e.url
}
}
}
return reportToURL
}
// enrichFromScanHistory fills JobURL from scan-history.tsv by matching company name.
func enrichFromScanHistory(careerOpsPath string, apps []model.CareerApplication) {
scanPath := filepath.Join(careerOpsPath, "scan-history.tsv")
scanData, err := os.ReadFile(scanPath)
if err != nil {
return
}
// Build company -> URL index from scan-history
type scanEntry struct {
url string
company string
title string
}
byCompany := make(map[string][]scanEntry)
for _, line := range strings.Split(string(scanData), "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 5 || fields[0] == "url" {
continue
}
url := fields[0]
company := fields[4]
title := fields[3]
if url == "" || !strings.HasPrefix(url, "http") {
continue
}
key := normalizeCompany(company)
byCompany[key] = append(byCompany[key], scanEntry{url: url, company: company, title: title})
}
for i := range apps {
if apps[i].JobURL != "" {
continue
}
key := normalizeCompany(apps[i].Company)
matches := byCompany[key]
if len(matches) == 1 {
apps[i].JobURL = matches[0].url
} else if len(matches) > 1 {
// Multiple entries: pick best role match
appRole := strings.ToLower(apps[i].Role)
best := matches[0].url
bestScore := 0
for _, m := range matches {
score := 0
mTitle := strings.ToLower(m.title)
for _, word := range strings.Fields(appRole) {
if len(word) > 2 && strings.Contains(mTitle, word) {
score++
}
}
if score > bestScore {
bestScore = score
best = m.url
}
}
apps[i].JobURL = best
}
}
}
// normalizeCompany strips common suffixes and lowercases a company name.
func normalizeCompany(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
for _, suffix := range []string{" inc.", " inc", " llc", " ltd", " corp", " corporation", " technologies", " technology", " group", " co."} {
s = strings.TrimSuffix(s, suffix)
}
return strings.TrimSpace(s)
}
// enrichAppURLsByCompany fills in JobURL for apps that didn't get one via report_num mapping.
// It matches by company name from batch-input.tsv notes.
func enrichAppURLsByCompany(careerOpsPath string, apps []model.CareerApplication) {
inputPath := filepath.Join(careerOpsPath, "batch", "batch-input.tsv")
inputData, err := os.ReadFile(inputPath)
if err != nil {
return
}
// Build company -> []entry index
type entry struct {
role string
url string
}
byCompany := make(map[string][]entry)
for _, line := range strings.Split(string(inputData), "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 4 || fields[0] == "id" {
continue
}
notes := fields[3]
var url string
if idx := strings.LastIndex(notes, "| "); idx >= 0 {
u := strings.TrimSpace(notes[idx+2:])
if strings.HasPrefix(u, "http") {
url = u
}
}
if url == "" && strings.HasPrefix(fields[1], "http") {
url = fields[1]
}
if url == "" {
continue
}
notesPart := notes
if pipeIdx := strings.Index(notesPart, " | "); pipeIdx >= 0 {
notesPart = notesPart[:pipeIdx]
}
if atIdx := strings.LastIndex(notesPart, " @ "); atIdx >= 0 {
role := strings.TrimSpace(notesPart[:atIdx])
company := strings.TrimSpace(notesPart[atIdx+3:])
key := normalizeCompany(company)
byCompany[key] = append(byCompany[key], entry{role: role, url: url})
}
}
for i := range apps {
if apps[i].JobURL != "" {
continue
}
key := normalizeCompany(apps[i].Company)
matches := byCompany[key]
if len(matches) == 1 {
apps[i].JobURL = matches[0].url
} else if len(matches) > 1 {
// Multiple entries for same company: pick best role match
appRole := strings.ToLower(apps[i].Role)
best := matches[0].url
bestScore := 0
for _, m := range matches {
score := 0
mRole := strings.ToLower(m.role)
// Count matching words
for _, word := range strings.Fields(appRole) {
if len(word) > 2 && strings.Contains(mRole, word) {
score++
}
}
if score > bestScore {
bestScore = score
best = m.url
}
}
apps[i].JobURL = best
}
}
}
// ComputeMetrics calculates aggregate metrics from applications.
func ComputeMetrics(apps []model.CareerApplication) model.PipelineMetrics {
m := model.PipelineMetrics{
Total: len(apps),
ByStatus: make(map[string]int),
}
var totalScore float64
var scored int
for _, app := range apps {
status := NormalizeStatus(app.Status)
m.ByStatus[status]++
if app.Score > 0 {
totalScore += app.Score
scored++
if app.Score > m.TopScore {
m.TopScore = app.Score
}
}
if app.HasPDF {
m.WithPDF++
}
if status != "skip" && status != "rejected" && status != "discarded" {
m.Actionable++
}
}
if scored > 0 {
m.AvgScore = totalScore / float64(scored)
}
return m
}
// NormalizeStatus normalizes raw status text to a canonical form.
// Aliases match states.yml -- keep in sync with career-ops/states.yml
func NormalizeStatus(raw string) string {
// Strip markdown bold and trailing dates
s := strings.ReplaceAll(raw, "**", "")
s = strings.TrimSpace(strings.ToLower(s))
// Strip trailing date (e.g., "aplicado 2026-03-12")
if idx := strings.Index(s, " 202"); idx > 0 {
s = strings.TrimSpace(s[:idx])
}
switch {
// Most restrictive first — accepts both English and Spanish
case strings.Contains(s, "no aplicar") || strings.Contains(s, "no_aplicar") || s == "skip" || strings.Contains(s, "geo blocker"):
return "skip"
case strings.Contains(s, "interview") || strings.Contains(s, "entrevista"):
return "interview"
case s == "offer" || strings.Contains(s, "oferta"):
return "offer"
case strings.Contains(s, "responded") || strings.Contains(s, "respondido"):
return "responded"
case strings.Contains(s, "applied") || strings.Contains(s, "aplicado") || s == "enviada" || s == "aplicada" || s == "sent":
return "applied"
case strings.Contains(s, "rejected") || strings.Contains(s, "rechazado") || s == "rechazada":
return "rejected"
case strings.Contains(s, "discarded") || strings.Contains(s, "descartado") || s == "descartada" || s == "cerrada" || s == "cancelada" ||
strings.HasPrefix(s, "duplicado") || strings.HasPrefix(s, "dup"):
return "discarded"
case strings.Contains(s, "evaluated") || strings.Contains(s, "evaluada") || s == "condicional" || s == "hold" || s == "monitor" || s == "evaluar" || s == "verificar":
return "evaluated"
default:
return s
}
}
// LoadReportSummary extracts key fields from a report file.
func LoadReportSummary(careerOpsPath, reportPath string) (archetype, tldr, remote, comp string) {
fullPath := filepath.Join(careerOpsPath, reportPath)
content, err := os.ReadFile(fullPath)
if err != nil {
return
}
text := string(content)
if m := reArchetype.FindStringSubmatch(text); m != nil {
archetype = cleanTableCell(m[1])
} else if m := reArchetypeColon.FindStringSubmatch(text); m != nil {
archetype = cleanTableCell(m[1])
} else if m := reArchetypeYAML.FindStringSubmatch(text); m != nil {
archetype = strings.TrimSpace(m[1])
}
// Try table-format TL;DR first (most reports), then colon format
if m := reTlDr.FindStringSubmatch(text); m != nil {
tldr = cleanTableCell(m[1])
} else if m := reTlDrColon.FindStringSubmatch(text); m != nil {
tldr = cleanTableCell(m[1])
}
if m := reRemote.FindStringSubmatch(text); m != nil {
remote = cleanTableCell(m[1])
}
if m := reComp.FindStringSubmatch(text); m != nil {
comp = cleanTableCell(m[1])
}
// Truncate long fields
if len(tldr) > 120 {
tldr = tldr[:117] + "..."
}
return
}
// splitTrackerRow splits a tracker table line into trimmed cell values, using
// the same delimiter logic as ParseApplications: a mixed "| " + tab-separated
// body, or a pure pipe-delimited row. Field 0 is the first real column (num), so
// the returned indices match the legacy layout (Status is field 5).
func splitTrackerRow(line string) []string {
line = strings.TrimSpace(line)
var fields []string
if strings.Contains(line, "\t") {
// Mixed format: starts with "| " then tab-separated.
line = strings.TrimPrefix(line, "|")
line = strings.TrimSpace(line)
for _, p := range strings.Split(line, "\t") {
fields = append(fields, strings.TrimSpace(strings.Trim(p, "|")))
}
} else {
// Pure pipe format.
line = strings.Trim(line, "|")
for _, p := range strings.Split(line, "|") {
fields = append(fields, strings.TrimSpace(p))
}
}
return fields
}
// trackerHeaderAliases maps a lowercased header cell to a canonical field name.
// Mirrors HEADER_ALIASES in tracker-parse.mjs (including the Spanish aliases) so
// the Go data layer tolerates the same customized layouts as the Node tracker
// tooling after #954.
var trackerHeaderAliases = map[string]string{
"#": "num", "num": "num", "date": "date",
"company": "company", "empresa": "company",
"role": "role", "puesto": "role",
"location": "location", "score": "score", "status": "status",
"pdf": "pdf", "report": "report", "notes": "notes",
}
// legacyTrackerColumns is the original fixed layout in splitTrackerRow field
// space (num=0 … notes=8), used when no recognizable header row is present.
var legacyTrackerColumns = map[string]int{
"num": 0, "date": 1, "company": 2, "role": 3, "score": 4,
"status": 5, "pdf": 6, "report": 7, "notes": 8,
}
// detectTrackerColumns scans for the table header row and maps canonical field
// names to column indices in splitTrackerRow field space. It returns nil unless
// the essential columns are all present, so a stray pipe line cannot yield a
// bogus mapping and the caller falls back to legacyTrackerColumns. Mirrors
// detectColumns in tracker-parse.mjs (#954).
func detectTrackerColumns(lines []string) map[string]int {
for _, line := range lines {
if !strings.HasPrefix(strings.TrimSpace(line), "|") {
continue
}
cells := splitTrackerRow(line)
m := make(map[string]int)
for i, c := range cells {
if name, ok := trackerHeaderAliases[strings.ToLower(c)]; ok {
if _, seen := m[name]; !seen {
m[name] = i
}
}
}
complete := true
for _, k := range []string{"num", "company", "role", "score", "status"} {
if _, ok := m[k]; !ok {
complete = false
break
}
}
if complete {
return m
}
}
return nil
}
// resolveTrackerColumns returns the header-detected column map, falling back to
// the legacy fixed layout when no header row is found.
func resolveTrackerColumns(lines []string) map[string]int {
if m := detectTrackerColumns(lines); m != nil {
return m
}
return legacyTrackerColumns
}
// UpdateApplicationStatus updates the status of an application in applications.md.
func UpdateApplicationStatus(careerOpsPath string, app model.CareerApplication, newStatus string) error {
filePath := filepath.Join(careerOpsPath, "applications.md")
content, err := os.ReadFile(filePath)
if err != nil {
filePath = filepath.Join(careerOpsPath, "data", "applications.md")
content, err = os.ReadFile(filePath)
if err != nil {
return err
}
}
lines := strings.Split(string(content), "\n")
found := false
// Locate the Status column by header name so a customized layout (e.g. an
// inserted Location column) is written to the right cell. Falls back to the
// legacy fixed index when no header is present.
statusIdx := resolveTrackerColumns(lines)["status"]
for i, line := range lines {
if !strings.HasPrefix(strings.TrimSpace(line), "|") {
continue
}
// Match by report number
if app.ReportNumber != "" && strings.Contains(line, fmt.Sprintf("[%s]", app.ReportNumber)) {
// Replace the status field
lines[i] = replaceStatusInLine(line, app.Status, newStatus, statusIdx)
found = true
break
}
}
if !found {
return fmt.Errorf("application not found: report %s", app.ReportNumber)
}
return os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0644)
}
// replaceStatusInLine rewrites only the Status cell of a tracker row, leaving
// every other cell untouched. The previous implementation used
// strings.Replace(line, oldStatus, …, 1), which replaces the first occurrence of
// the status text anywhere in the row — so a status word appearing as a
// substring of an earlier cell (e.g. Company "Applied Materials") was rewritten
// instead of the Status cell, corrupting that cell while the status appeared to
// stay unchanged (#1180). Matching is whole-cell (never a substring) and, as the
// old comment claimed but the code did not, case-insensitive.
//
// statusField is the Status column index in splitTrackerRow field space (5 in
// the legacy layout), resolved from the table header so a customized layout
// (e.g. an inserted Location column) targets the right cell.
func replaceStatusInLine(line, oldStatus, newStatus string, statusField int) string {
want := strings.TrimSpace(oldStatus)
// Mixed "| " + tab-separated format (mirrors ParseApplications). The body is
// tab-split, so cell index equals the field index.
if strings.Contains(line, "\t") {
prefix, body, found := strings.Cut(line, "|")
if !found {
return line
}
cells := strings.Split(body, "\t")
if idx := statusCellIndex(cells, statusField, want); idx >= 0 {
cells[idx] = spliceCellValue(cells[idx], newStatus)
return prefix + "|" + strings.Join(cells, "\t")
}
return line
}
// Pure pipe format. strings.Split keeps the segments between pipes; content
// cell N is segment N+1 (segment 0 is the empty text before the leading
// pipe), so the Status field maps to segment statusField+1.
segments := strings.Split(line, "|")
if idx := statusCellIndex(segments, statusField+1, want); idx >= 0 {
segments[idx] = spliceCellValue(segments[idx], newStatus)
return strings.Join(segments, "|")
}
return line
}
// statusCellIndex returns the index of the Status cell. It prefers the canonical
// column (canonicalIdx, matching ParseApplications) and verifies it by value; if
// that doesn't match — e.g. a custom tracker layout — it falls back to the first
// cell that equals want exactly. Matching is whole-cell and case-insensitive,
// never a substring, so a status word inside an earlier cell is never hit.
// Returns -1 when nothing matches, so the caller leaves the row untouched rather
// than corrupt a guess.
func statusCellIndex(cells []string, canonicalIdx int, want string) int {
if canonicalIdx < len(cells) && strings.EqualFold(strings.TrimSpace(cells[canonicalIdx]), want) {
return canonicalIdx
}
for i, c := range cells {
if strings.EqualFold(strings.TrimSpace(c), want) {
return i
}
}
return -1
}
// spliceCellValue swaps a cell's inner value while preserving its surrounding
// whitespace, so "| Applied |" becomes "| Interview |" rather than "|Interview|".
func spliceCellValue(cell, newVal string) string {
trimmed := strings.TrimSpace(cell)
if trimmed == "" {
return cell
}
start := strings.Index(cell, trimmed)
return cell[:start] + newVal + cell[start+len(trimmed):]
}
// cleanTableCell removes trailing pipes and whitespace from a table cell value.
func cleanTableCell(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimRight(s, "|")
return strings.TrimSpace(s)
}
// StatusPriority returns the sort priority for a status (lower = higher priority).
func StatusPriority(status string) int {
switch NormalizeStatus(status) {
case "interview":
return 0
case "offer":
return 1
case "responded":
return 2
case "applied":
return 3
case "evaluated":
return 4
case "skip":
return 5
case "rejected":
return 6
case "discarded":
return 7
default:
return 8
}
}
// ComputeProgressMetrics computes progress-oriented analytics from applications.
func ComputeProgressMetrics(apps []model.CareerApplication) model.ProgressMetrics {
pm := model.ProgressMetrics{}
// Count by normalized status
statusCounts := make(map[string]int)
var totalScore float64
var scored int
for _, app := range apps {
norm := NormalizeStatus(app.Status)
statusCounts[norm]++
if app.Score > 0 {
totalScore += app.Score
scored++
if app.Score > pm.TopScore {
pm.TopScore = app.Score
}
}
if norm == "offer" {
pm.TotalOffers++
}
if norm != "skip" && norm != "rejected" && norm != "discarded" {
pm.ActiveApps++
}
}
if scored > 0 {
pm.AvgScore = totalScore / float64(scored)
}
// Funnel: each stage counts all apps that reached at least that stage.
// An app in "interview" has passed through evaluated -> applied -> responded -> interview.
total := len(apps)
applied := statusCounts["applied"] + statusCounts["responded"] + statusCounts["interview"] + statusCounts["offer"] + statusCounts["rejected"]
responded := statusCounts["responded"] + statusCounts["interview"] + statusCounts["offer"]
interview := statusCounts["interview"] + statusCounts["offer"]
offer := statusCounts["offer"]
pm.FunnelStages = []model.FunnelStage{
{Label: "Evaluated", Count: total, Pct: 100.0},
{Label: "Applied", Count: applied, Pct: safePct(applied, total)},
{Label: "Responded", Count: responded, Pct: safePct(responded, applied)},
{Label: "Interview", Count: interview, Pct: safePct(interview, applied)},
{Label: "Offer", Count: offer, Pct: safePct(offer, applied)},
}
// Rates (relative to applied)
if applied > 0 {
pm.ResponseRate = float64(responded) / float64(applied) * 100
pm.InterviewRate = float64(interview) / float64(applied) * 100
pm.OfferRate = float64(offer) / float64(applied) * 100
}
// Score distribution
buckets := [5]int{} // 0: 4.5-5.0, 1: 4.0-4.4, 2: 3.5-3.9, 3: 3.0-3.4, 4: <3.0
for _, app := range apps {
if app.Score <= 0 {
continue
}
switch {
case app.Score >= 4.5:
buckets[0]++
case app.Score >= 4.0:
buckets[1]++
case app.Score >= 3.5:
buckets[2]++
case app.Score >= 3.0:
buckets[3]++
default:
buckets[4]++
}
}
pm.ScoreBuckets = []model.ScoreBucket{
{Label: "4.5-5.0", Count: buckets[0]},
{Label: "4.0-4.4", Count: buckets[1]},
{Label: "3.5-3.9", Count: buckets[2]},
{Label: "3.0-3.4", Count: buckets[3]},
{Label: " <3.0", Count: buckets[4]},
}
// Weekly activity: group by ISO week from Date field, show last 8 weeks.
weekCounts := make(map[string]int)
for _, app := range apps {
if app.Date == "" {
continue
}
t, err := time.Parse("2006-01-02", app.Date)
if err != nil {
continue
}
year, week := t.ISOWeek()
key := fmt.Sprintf("%d-W%02d", year, week)
weekCounts[key]++
}
// Sort weeks and take last 8
var weeks []string
for w := range weekCounts {
weeks = append(weeks, w)
}
sort.Strings(weeks)
if len(weeks) > 8 {
weeks = weeks[len(weeks)-8:]
}
for _, w := range weeks {
pm.WeeklyActivity = append(pm.WeeklyActivity, model.WeekActivity{
Week: w,
Count: weekCounts[w],
})
}
return pm
}
// safePct returns the percentage of part/whole, or 0 if whole is 0.
func safePct(part, whole int) float64 {
if whole == 0 {
return 0
}
return float64(part) / float64(whole) * 100
}
+266
View File
@@ -0,0 +1,266 @@
package data
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Regression for #1180: a status word appearing as a substring of an earlier
// cell (Company "Applied Materials" contains "Applied") must not be rewritten;
// only the Status column changes.
func TestUpdateApplicationStatusOnlyRewritesStatusColumn(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatalf("failed to create data dir: %v", err)
}
applications := `# Applications Tracker
| # | Date | Company | Role | Score | Status | PDF | Report | Notes |
|---|------|---------|------|-------|--------|-----|--------|-------|
| 7 | 2026-06-23 | Applied Materials | Staff Android Engineer | 4.2/5 | Applied | ✅ | [7](reports/007.md) | substring trap |
`
path := filepath.Join(dataDir, "applications.md")
if err := os.WriteFile(path, []byte(applications), 0o644); err != nil {
t.Fatalf("failed to write tracker: %v", err)
}
apps := ParseApplications(tempDir)
if len(apps) != 1 {
t.Fatalf("expected 1 parsed application, got %d", len(apps))
}
if err := UpdateApplicationStatus(tempDir, apps[0], "Interview"); err != nil {
t.Fatalf("UpdateApplicationStatus: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
out := string(got)
if !strings.Contains(out, "| Applied Materials |") {
t.Errorf("Company cell was corrupted, file now:\n%s", out)
}
if !strings.Contains(out, "| Interview |") {
t.Errorf("Status cell was not updated to Interview, file now:\n%s", out)
}
if strings.Contains(out, "Interview Materials") {
t.Errorf("status word was replaced inside the Company cell, file now:\n%s", out)
}
reparsed := ParseApplications(tempDir)
if reparsed[0].Company != "Applied Materials" {
t.Errorf("company = %q, want \"Applied Materials\"", reparsed[0].Company)
}
if reparsed[0].Status != "Interview" {
t.Errorf("status = %q, want \"Interview\"", reparsed[0].Status)
}
}
func TestParseApplicationsUsesTrackerNumberColumn(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatalf("failed to create data dir: %v", err)
}
applications := `# Applications Tracker
| # | Date | Company | Role | Score | Status | PDF | Report | Notes |
|---|------|---------|------|-------|--------|-----|--------|-------|
| 140 | 2026-04-16 | Arize AI | AI Engineer, Instrumentation | 4.7/5 | Evaluated | ✅ | [140](reports/140-arize-ai-engineer-instrumentation-2026-04-16.md) | Strong fit |
| 143 | 2026-04-16 | Arize AI | AI Sales Engineer, US | 4.1/5 | Evaluated | ❌ | [143](reports/143-arize-ai-sales-engineer-us-2026-04-16.md) | Good fit |
`
applicationsPath := filepath.Join(dataDir, "applications.md")
if err := os.WriteFile(applicationsPath, []byte(applications), 0o644); err != nil {
t.Fatalf("failed to write applications tracker: %v", err)
}
apps := ParseApplications(tempDir)
if len(apps) != 2 {
t.Fatalf("expected 2 parsed applications, got %d", len(apps))
}
if apps[0].Number != 140 {
t.Fatalf("expected first application number to be 140, got %d", apps[0].Number)
}
if apps[1].Number != 143 {
t.Fatalf("expected second application number to be 143, got %d", apps[1].Number)
}
if apps[0].ReportNumber != "140" || apps[1].ReportNumber != "143" {
t.Fatalf("expected report numbers to stay aligned with tracker IDs, got %q and %q", apps[0].ReportNumber, apps[1].ReportNumber)
}
}
func TestParseApplicationsResolvesTrackerRelativeReportLinks(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "data")
reportsDir := filepath.Join(tempDir, "reports")
for _, dir := range []string{dataDir, reportsDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("failed to create dir %s: %v", dir, err)
}
}
// Tracker links are written relative to the tracker file itself
// (merge-tracker.mjs normalization): ../reports/... when the tracker
// lives under data/. Legacy trackers may still carry root-relative
// links; both must resolve to the same on-disk report.
applications := `# Applications Tracker
| # | Date | Company | Role | Score | Status | PDF | Report | Notes |
|---|------|---------|------|-------|--------|-----|--------|-------|
| 1 | 2026-06-03 | Acme | Engineer | 4.0/5 | Evaluated | ✅ | [1](../reports/001-acme-2026-06-03.md) | Tracker-relative link |
| 2 | 2026-06-03 | Legacy Co | Engineer | 3.0/5 | Evaluated | ❌ | [2](reports/002-legacy-2026-06-03.md) | Legacy root-relative link |
`
if err := os.WriteFile(filepath.Join(dataDir, "applications.md"), []byte(applications), 0o644); err != nil {
t.Fatalf("failed to write applications tracker: %v", err)
}
for _, name := range []string{"001-acme-2026-06-03.md", "002-legacy-2026-06-03.md"} {
if err := os.WriteFile(filepath.Join(reportsDir, name), []byte("# Report\n"), 0o644); err != nil {
t.Fatalf("failed to write report %s: %v", name, err)
}
}
apps := ParseApplications(tempDir)
if len(apps) != 2 {
t.Fatalf("expected 2 parsed applications, got %d", len(apps))
}
wantFirst := filepath.Join("reports", "001-acme-2026-06-03.md")
if apps[0].ReportPath != wantFirst {
t.Fatalf("expected tracker-relative link to resolve to %q, got %q", wantFirst, apps[0].ReportPath)
}
wantSecond := filepath.Join("reports", "002-legacy-2026-06-03.md")
if apps[1].ReportPath != wantSecond {
t.Fatalf("expected legacy root-relative link to resolve to %q, got %q", wantSecond, apps[1].ReportPath)
}
// Every consumer joins ReportPath against careerOpsPath — both rows
// must point at files that exist.
for i, app := range apps {
if _, err := os.Stat(filepath.Join(tempDir, app.ReportPath)); err != nil {
t.Fatalf("row %d: resolved report path %q does not exist: %v", i, app.ReportPath, err)
}
}
}
// writeTracker writes applications.md under data/ and returns the temp root and
// the tracker path.
func writeTracker(t *testing.T, body string) (string, string) {
t.Helper()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dataDir, "applications.md")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write tracker: %v", err)
}
return tempDir, path
}
const insertedColumnTracker = `# Applications Tracker
| # | Date | Company | Role | Location | Score | Status | PDF | Report | Notes |
|---|------|---------|------|----------|-------|--------|-----|--------|-------|
| 1 | 2026-06-01 | Acme | VP Marketing | Remote | 4.5/5 | Applied | ✅ | [1](reports/001.md) | hot lead |
`
// A tracker with a Location column inserted before Score (the customized layout
// the Node tracker tooling supports since #954) must not desync the Go reader.
// Without header-aware mapping, Status reads the Score cell and the report link
// reads the PDF cell, so ReportNumber comes back empty.
func TestParseApplicationsMapsColumnsByHeader(t *testing.T) {
tempDir, _ := writeTracker(t, insertedColumnTracker)
apps := ParseApplications(tempDir)
if len(apps) != 1 {
t.Fatalf("expected 1 application, got %d", len(apps))
}
a := apps[0]
if a.Company != "Acme" {
t.Errorf("Company = %q, want \"Acme\"", a.Company)
}
if a.Role != "VP Marketing" {
t.Errorf("Role = %q, want \"VP Marketing\"", a.Role)
}
if a.Status != "Applied" {
t.Errorf("Status = %q, want \"Applied\"", a.Status)
}
if a.ScoreRaw != "4.5/5" {
t.Errorf("ScoreRaw = %q, want \"4.5/5\"", a.ScoreRaw)
}
if !a.HasPDF {
t.Errorf("HasPDF = false, want true")
}
if a.ReportNumber != "1" {
t.Errorf("ReportNumber = %q, want \"1\"", a.ReportNumber)
}
}
// End-to-end status update on the inserted-column layout: parse, update, and
// re-parse. Only the Status cell may change; every other cell stays intact.
func TestUpdateApplicationStatusInsertedColumn(t *testing.T) {
tempDir, path := writeTracker(t, insertedColumnTracker)
apps := ParseApplications(tempDir)
if len(apps) != 1 {
t.Fatalf("expected 1 application, got %d", len(apps))
}
if err := UpdateApplicationStatus(tempDir, apps[0], "Interview"); err != nil {
t.Fatalf("UpdateApplicationStatus: %v", err)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
if !strings.Contains(got, "| Interview |") {
t.Errorf("Status cell not updated to Interview, file now:\n%s", got)
}
if strings.Count(got, "Interview") != 1 {
t.Errorf("write touched an unintended cell; %d occurrences of Interview:\n%s", strings.Count(got, "Interview"), got)
}
for _, cell := range []string{"| Acme |", "| VP Marketing |", "| Remote |", "| 4.5/5 |", "| ✅ |"} {
if !strings.Contains(got, cell) {
t.Errorf("expected intact cell %q missing after write:\n%s", cell, got)
}
}
reparsed := ParseApplications(tempDir)
if reparsed[0].Status != "Interview" {
t.Errorf("reparsed Status = %q, want \"Interview\"", reparsed[0].Status)
}
if reparsed[0].ScoreRaw != "4.5/5" {
t.Errorf("reparsed ScoreRaw = %q, want \"4.5/5\"", reparsed[0].ScoreRaw)
}
}
// resolveTrackerColumns detects the header layout, and falls back to the legacy
// fixed layout when no recognizable header row is present.
func TestResolveTrackerColumns(t *testing.T) {
header := strings.Split(insertedColumnTracker, "\n")
cols := resolveTrackerColumns(header)
if cols["status"] != 6 {
t.Errorf("status index = %d, want 6 (inserted Location column)", cols["status"])
}
if cols["score"] != 5 {
t.Errorf("score index = %d, want 5", cols["score"])
}
headerless := []string{"| 1 | 2026-06-01 | Acme | VP Marketing | 4.5/5 | Applied | ✅ | [1](reports/001.md) | note |"}
fallback := resolveTrackerColumns(headerless)
if fallback["status"] != 5 {
t.Errorf("fallback status index = %d, want 5 (legacy layout)", fallback["status"])
}
}
+130
View File
@@ -0,0 +1,130 @@
package data
import (
"regexp"
"strconv"
"strings"
"github.com/santifer/career-ops/dashboard/internal/model"
)
// The tracker's Notes column is free-text, but evaluations write it with stable
// conventions: work mode ("Remote US", "Charlotte NC (Hybrid)"), a pay range
// ("$140-210K (POSTED)" / "~$150-220K (est)") and event dates ("Rejected
// 2026-06-04"). These regexes lift that structure back out so the dashboard can
// show Location / Pay / Last-contact columns without a tracker schema change.
var (
// Pay amounts across the currencies career-ops users actually see, optionally
// a range: "$140-210K", "€130-160K", "£175-225K", "CHF 165-185K",
// "$174,986-209,983", "~$124.2-198.7K". payCeiling stays currency-naive (it
// reads the numbers), so PayMax sorts by magnitude across currencies.
reMoneySpan = regexp.MustCompile(`~?(?:[$€£]|CHF ?|EUR ?|USD ?|GBP ?)\d[\d,]*(?:\.\d+)?[KkMm]?(?:\s*[-–]\s*(?:[$€£])?\d[\d,]*(?:\.\d+)?[KkMm]?)?`)
// ISO dates embedded in notes ("Rejected 2026-06-04", "viewed 2026-06-04")
reISODate = regexp.MustCompile(`\b20\d{2}-\d{2}-\d{2}\b`)
// "City ST" / "City, ST" with a strict two-letter US state code so prose like
// "Sams AI" or "Kerin Colby DONE" can't false-positive.
reCityState = regexp.MustCompile(`\b([A-Z][A-Za-z.'-]+(?: [A-Z][A-Za-z.'-]+){0,2}),? (A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])\b`)
// International cities, checked only when no US "City, ST" matches, so
// European/other non-US roles still surface a Location. Cities only (not bare
// country names) to avoid prose false-positives like "Portugal eligible" or
// "remote in Germany", which describe eligibility, not the job's location.
reCityIntl = regexp.MustCompile(`(?i)\b(Porto|Lisbon|London|Berlin|Munich|Hamburg|Frankfurt|Cologne|D(?:ü|u)sseldorf|Stuttgart|Z(?:ü|u)rich|Geneva|Lausanne|Basel|Dublin|Cork|Amsterdam|Rotterdam|Eindhoven|Utrecht|Paris|Lyon|Madrid|Barcelona|Valencia|Stockholm|Gothenburg|Malm(?:ö|o)|Copenhagen|Oslo|Helsinki|Milan|Rome|Turin|Vienna|Brussels|Ghent|Antwerp|Luxembourg|Warsaw|Krak(?:ó|o)w|Wroc(?:ł|l)aw|Tallinn|Riga|Vilnius|Prague|Brno|Budapest|Bucharest|Sofia|Athens|Bengaluru|Bangalore|Singapore|Sydney|Toronto|Vancouver|Tel Aviv|S(?:ã|a)o Paulo)\b`)
// Individual amounts inside an already-matched span: "140", "210K", "209,983"
reMoneyPart = regexp.MustCompile(`(\d[\d,]*(?:\.\d+)?)\s*([KkMm]?)`)
// Estimate markers: "(est)", "(est;", "market est)" or "market" as its own
// word — but not "(EST/CST" timezones, "interest)" or "marketing".
reEstHint = regexp.MustCompile(`\(est[),;. ]|\best\)|\bmarket\b`)
)
// payCeiling converts a matched pay span to its top dollar amount for sorting:
// "$140-210K" → 210000, "$174,986-209,983" → 209983, "$170K" → 170000.
func payCeiling(span string) float64 {
top := 0.0
for _, p := range reMoneyPart.FindAllStringSubmatch(span, -1) {
v, err := strconv.ParseFloat(strings.ReplaceAll(p[1], ",", ""), 64)
if err != nil {
continue
}
switch strings.ToLower(p[2]) {
case "k":
v *= 1_000
case "m":
v *= 1_000_000
}
if v > top {
top = v
}
}
return top
}
// deriveNoteFields populates Location, WorkMode, PayRange, PaySource and
// LastContact from the application's Notes (plus Role for work-mode keywords).
func deriveNoteFields(app *model.CareerApplication) {
lower := strings.ToLower(app.Role + " " + app.Notes)
// Location: first "City, ST" in the notes, falling back to the role title
// (some tracker rows carry the city there, e.g. "... — Charlotte, NC"). When
// no US "City, ST" is present, fall back to an international city/country so
// European and other non-US roles still show a Location.
if m := reCityState.FindStringSubmatch(app.Notes); m != nil {
app.Location = m[1] + ", " + m[2]
} else if m := reCityState.FindStringSubmatch(app.Role); m != nil {
app.Location = m[1] + ", " + m[2]
} else if m := reCityIntl.FindString(app.Notes); m != "" {
app.Location = m
} else if m := reCityIntl.FindString(app.Role); m != "" {
app.Location = m
}
// Work mode: hybrid beats remote ("Remote/hybrid" means office days exist);
// "remote-first" / "remote + flex" is softer than fully remote;
// a bare city+state with no keyword implies fully on-site.
switch {
case strings.Contains(lower, "hybrid"):
app.WorkMode = "Hybrid"
case strings.Contains(lower, "remote") &&
(strings.Contains(lower, "flex") ||
strings.Contains(lower, "remote-first") ||
strings.Contains(lower, "remote first")):
app.WorkMode = "RemoteFlex"
case strings.Contains(lower, "remote"):
app.WorkMode = "Remote"
case strings.Contains(lower, "onsite") || strings.Contains(lower, "on-site") || strings.Contains(lower, "in-office"):
app.WorkMode = "Full"
case app.Location != "":
app.WorkMode = "Full"
}
// Pay: prefer the first $-range; fall back to the first lone $-amount
// (e.g. "$170K min floor") only when no range exists.
matches := reMoneySpan.FindAllString(app.Notes, -1)
for _, mm := range matches {
if strings.ContainsAny(mm, "-–") {
app.PayRange = mm
break
}
}
if app.PayRange == "" && len(matches) > 0 {
app.PayRange = matches[0]
}
app.PayMax = payCeiling(app.PayRange)
if app.PayRange != "" {
switch {
case strings.Contains(lower, "(posted"):
app.PaySource = "POSTED"
case reEstHint.MatchString(lower):
app.PaySource = "est"
}
}
// Last contact: the most recent ISO date mentioned anywhere in the notes
// (rejections, recruiter views, phone screens), else the applied date.
last := app.Date
for _, d := range reISODate.FindAllString(app.Notes, -1) {
if d > last {
last = d
}
}
app.LastContact = last
}
+212
View File
@@ -0,0 +1,212 @@
package data
import (
"testing"
"github.com/santifer/career-ops/dashboard/internal/model"
)
func TestDeriveNoteFields(t *testing.T) {
cases := []struct {
name string
app model.CareerApplication
location string
workMode string
payRange string
paySrc string
last string
}{
{
name: "remote with posted comma range and rejection date",
app: model.CareerApplication{
Date: "2026-06-04",
Notes: "Remote US (EST/CST). Base $174,986-209,983 + RSUs (POSTED). Rejected 2026-06-05 (not moving forward). Via Greenhouse",
},
workMode: "Remote",
payRange: "$174,986-209,983",
paySrc: "POSTED",
last: "2026-06-05",
},
{
name: "hybrid city state with estimate",
app: model.CareerApplication{
Date: "2026-06-03",
Notes: "Charlotte NC (Hybrid), via LinkedIn. Comp ~$130-170K (est). Application VIEWED by recruiter 2026-06-04",
},
location: "Charlotte, NC",
workMode: "Hybrid",
payRange: "~$130-170K",
paySrc: "est",
last: "2026-06-04",
},
{
name: "bare location implies full onsite, decimal K range",
app: model.CareerApplication{
Date: "2026-06-01",
Notes: "Austin TX (location mismatch). Salary $124.2-198.7K (POSTED)",
},
location: "Austin, TX",
workMode: "Full",
payRange: "$124.2-198.7K",
paySrc: "POSTED",
last: "2026-06-01",
},
{
name: "lone amount fallback when no range, date falls back to applied",
app: model.CareerApplication{
Date: "2026-06-02",
Notes: "Via LinkedIn (recruiting agency). Sam stated $170K min floor",
},
workMode: "",
payRange: "$170K",
last: "2026-06-02",
},
{
name: "range preferred over earlier lone amount",
app: model.CareerApplication{
Date: "2026-05-31",
Notes: "Comp $100-175K base + 10% bonus + $300 health credit (recruiter-confirmed). Phone screen DONE 2026-06-03",
},
payRange: "$100-175K",
last: "2026-06-03",
},
{
name: "city falls back to role title, timezone parens are not an estimate",
app: model.CareerApplication{
Date: "2026-05-31",
Role: "Sr Software Engineer, Enterprise Systems — Charlotte, NC",
Notes: "Referral via friend. Remote US (EST/CST). Comp $100-175K base (recruiter-confirmed)",
},
location: "Charlotte, NC",
workMode: "Remote",
payRange: "$100-175K",
paySrc: "",
last: "2026-05-31",
},
{
name: "marketing role and interest prose are not estimate markers",
app: model.CareerApplication{
Date: "2026-06-01",
Role: "Product Marketing Manager",
Notes: "Strong fit (AI-augmented interest). Salary $140-180K. Via Lever",
},
payRange: "$140-180K",
paySrc: "",
last: "2026-06-01",
},
{
name: "no false-positive city from prose",
app: model.CareerApplication{
Date: "2026-06-01",
Notes: "Strong fit for Sams AI-augmented edge. Rejected by recruiter Nadia Kong",
},
location: "",
workMode: "",
last: "2026-06-01",
},
{
name: "remote EU with EUR estimate range",
app: model.CareerApplication{
Date: "2026-06-10",
Notes: "Remote EU (Portugal eligible). Comp ~€130-170K (est). Applied 2026-06-10",
},
location: "",
workMode: "Remote",
payRange: "~€130-170K",
paySrc: "est",
last: "2026-06-10",
},
{
name: "international city hybrid with EUR posted",
app: model.CareerApplication{
Date: "2026-06-09",
Notes: "Berlin (Hybrid). Base €90-110K (POSTED). Via Greenhouse",
},
location: "Berlin",
workMode: "Hybrid",
payRange: "€90-110K",
paySrc: "POSTED",
last: "2026-06-09",
},
{
name: "CHF range, bare intl city implies onsite",
app: model.CareerApplication{
Date: "2026-06-08",
Notes: "Zurich. CHF 165-185K. Check Point-backed",
},
location: "Zurich",
workMode: "Full",
payRange: "CHF 165-185K",
paySrc: "",
last: "2026-06-08",
},
{
name: "GBP posted range, London",
app: model.CareerApplication{
Date: "2026-06-07",
Notes: "London. £175-225K (POSTED). 30+ teams",
},
location: "London",
workMode: "Full",
payRange: "£175-225K",
paySrc: "POSTED",
last: "2026-06-07",
},
{
name: "Portugal home market, city from role title",
app: model.CareerApplication{
Date: "2026-06-06",
Role: "Senior Engineering Manager — Porto",
Notes: "Onsite. Comp €60-90K (est, below floor)",
},
location: "Porto",
workMode: "Full",
payRange: "€60-90K",
paySrc: "est",
last: "2026-06-06",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
deriveNoteFields(&tc.app)
if want := payCeiling(tc.payRange); tc.app.PayMax != want {
t.Errorf("PayMax = %v, want %v", tc.app.PayMax, want)
}
if tc.app.Location != tc.location {
t.Errorf("Location = %q, want %q", tc.app.Location, tc.location)
}
if tc.app.WorkMode != tc.workMode {
t.Errorf("WorkMode = %q, want %q", tc.app.WorkMode, tc.workMode)
}
if tc.app.PayRange != tc.payRange {
t.Errorf("PayRange = %q, want %q", tc.app.PayRange, tc.payRange)
}
if tc.app.PaySource != tc.paySrc {
t.Errorf("PaySource = %q, want %q", tc.app.PaySource, tc.paySrc)
}
if tc.app.LastContact != tc.last {
t.Errorf("LastContact = %q, want %q", tc.app.LastContact, tc.last)
}
})
}
}
func TestPayCeiling(t *testing.T) {
cases := map[string]float64{
"$140-210K": 210_000,
"$174,986-209,983": 209_983,
"~$124.2-198.7K": 198_700,
"$170K": 170_000,
"$95-159K": 159_000,
"€130-170K": 170_000,
"£175-225K": 225_000,
"CHF 165-185K": 185_000,
"": 0,
}
for span, want := range cases {
if got := payCeiling(span); got != want {
t.Errorf("payCeiling(%q) = %v, want %v", span, got, want)
}
}
}
+296
View File
@@ -0,0 +1,296 @@
package data
import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/santifer/career-ops/dashboard/internal/model"
)
// PDFManifestEntry is one row of data/pdf-index.tsv, written by
// generate-pdf.mjs each time a PDF is produced. ReportNumber may be empty
// for PDFs generated without --report; those rows still help the glob
// matcher via their recorded paths.
type PDFManifestEntry struct {
ReportNumber string
PDFPath string // relative to the career-ops root
HTMLPath string // relative to the career-ops root, "" if unknown
Format string // "letter" or "a4"
Date string // YYYY-MM-DD generation date
}
// PDFManifest indexes manifest entries by normalized report number (leading
// zeros stripped, so "008" and "8" collide deliberately). Rows without a
// report number are not indexed (the glob path covers them).
type PDFManifest map[string]PDFManifestEntry
// isSafeRepoRelativePath returns true iff p is a non-empty, non-absolute path
// with no ".." traversal components. Used to reject crafted TSV rows before
// they reach dashboard open/regenerate flows.
func isSafeRepoRelativePath(p string) bool {
p = filepath.Clean(filepath.FromSlash(strings.TrimSpace(p)))
if p == "." || p == "" {
return false
}
if filepath.IsAbs(p) {
return false
}
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
return false
}
return true
}
// normalizeReportKey strips leading zeros so the zero-padded report-link
// form ("008") and the unpadded tracker-# form ("8") key identically.
func normalizeReportKey(s string) string {
s = strings.TrimSpace(s)
trimmed := strings.TrimLeft(s, "0")
if trimmed == "" && s != "" {
return "0"
}
return trimmed
}
// Lookup finds the manifest entry for an application, trying the report-link
// number first (the NNN in reports/NNN-….md) and falling back to the tracker
// # column. The two usually agree but are scrambled in real trackers, and
// callers passing --report may have used either — tolerate both.
func (m PDFManifest) Lookup(app model.CareerApplication) (PDFManifestEntry, bool) {
if key := normalizeReportKey(app.ReportNumber); key != "" {
if entry, ok := m[key]; ok {
return entry, true
}
}
if app.Number > 0 {
if entry, ok := m[strconv.Itoa(app.Number)]; ok {
return entry, true
}
}
return PDFManifestEntry{}, false
}
// LoadPDFEntriesByPath reads data/pdf-index.tsv and returns all entries indexed
// by their relative PDF path. Unlike LoadPDFManifest, this includes rows that
// were generated without a --report flag. Later rows in the file win.
func LoadPDFEntriesByPath(careerOpsPath string) map[string]PDFManifestEntry {
byPath := make(map[string]PDFManifestEntry)
raw, err := os.ReadFile(filepath.Join(careerOpsPath, "data", "pdf-index.tsv"))
if err != nil {
return byPath
}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 2 {
continue
}
entry := PDFManifestEntry{
ReportNumber: strings.TrimSpace(fields[0]),
PDFPath: strings.TrimSpace(fields[1]),
}
if entry.PDFPath == "" || !isSafeRepoRelativePath(entry.PDFPath) {
continue
}
if len(fields) > 2 {
entry.HTMLPath = strings.TrimSpace(fields[2])
if entry.HTMLPath != "" && !isSafeRepoRelativePath(entry.HTMLPath) {
entry.HTMLPath = ""
}
}
if len(fields) > 3 {
entry.Format = strings.TrimSpace(fields[3])
}
if len(fields) > 4 {
entry.Date = strings.TrimSpace(fields[4])
}
byPath[entry.PDFPath] = entry
}
return byPath
}
// LoadPDFManifest reads data/pdf-index.tsv under careerOpsPath. A missing
// file is not an error — the manifest is optional and absent until the
// first generate-pdf.mjs run that writes it. Later rows win over earlier
// ones for the same report number, so regenerated PDFs supersede stale
// entries without any compaction step.
func LoadPDFManifest(careerOpsPath string) PDFManifest {
manifest := make(PDFManifest)
raw, err := os.ReadFile(filepath.Join(careerOpsPath, "data", "pdf-index.tsv"))
if err != nil {
return manifest
}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 2 {
continue
}
entry := PDFManifestEntry{
ReportNumber: strings.TrimSpace(fields[0]),
PDFPath: strings.TrimSpace(fields[1]),
}
if len(fields) > 2 {
entry.HTMLPath = strings.TrimSpace(fields[2])
if entry.HTMLPath != "" && !isSafeRepoRelativePath(entry.HTMLPath) {
entry.HTMLPath = ""
}
}
if len(fields) > 3 {
entry.Format = strings.TrimSpace(fields[3])
}
if len(fields) > 4 {
entry.Date = strings.TrimSpace(fields[4])
}
if entry.ReportNumber == "" || entry.PDFPath == "" || !isSafeRepoRelativePath(entry.PDFPath) {
continue
}
manifest[normalizeReportKey(entry.ReportNumber)] = entry
}
return manifest
}
// rePDFDate extracts the trailing YYYY-MM-DD stamp from generated CV
// filenames (cv-{candidate}-{slug}-{date}.pdf).
var rePDFDate = regexp.MustCompile(`(\d{4}-\d{2}-\d{2})\.pdf$`)
// ResolvePDFs returns candidate PDF paths (relative to careerOpsPath) for an
// application, best match first.
//
// Precedence:
// 1. Manifest entry for the application's report number, when the file
// still exists. This is exact — generate-pdf.mjs recorded the linkage.
// 2. Filename match: output/cv-*.pdf whose name contains the kebab-cased
// company. This covers every PDF generated before the manifest existed.
// Multiple matches are all returned (newest first) so the caller can
// offer a picker instead of guessing — one company can have several
// role-variant CVs from the same day.
func ResolvePDFs(careerOpsPath string, app model.CareerApplication, manifest PDFManifest) []string {
if entry, ok := manifest.Lookup(app); ok {
if _, err := os.Stat(filepath.Join(careerOpsPath, filepath.FromSlash(entry.PDFPath))); err == nil {
return []string{entry.PDFPath}
}
}
slug := kebabCase(app.Company)
if slug == "" {
return nil
}
globbed, err := filepath.Glob(filepath.Join(careerOpsPath, "output", "cv-*.pdf"))
if err != nil {
return nil
}
var matches []string
for _, p := range globbed {
base := strings.ToLower(filepath.Base(p))
if matchesCompanySlug(base, slug) {
if rel, err := filepath.Rel(careerOpsPath, p); err == nil {
matches = append(matches, filepath.ToSlash(rel))
}
}
}
sortPDFsNewestFirst(careerOpsPath, matches)
return matches
}
// matchesCompanySlug reports whether a generated CV filename refers to the
// company. Short slugs (< 3 runes) require a full "-slug-" segment so a
// company like "X" can't match every file; longer slugs use substring
// containment, which tolerates role-variant suffixes (cv-…-anthropic-staff-
// ui-….pdf matches "anthropic").
func matchesCompanySlug(base, slug string) bool {
if len([]rune(slug)) < 3 {
return strings.Contains(base, "-"+slug+"-")
}
return strings.Contains(base, slug)
}
// sortPDFsNewestFirst orders candidate paths by the date stamp embedded in
// the filename (descending), falling back to file mtime when the stamp is
// missing or equal. A regenerated CV from today therefore outranks last
// week's, and same-day variants get a stable mtime ordering.
func sortPDFsNewestFirst(careerOpsPath string, paths []string) {
dateOf := func(p string) string {
if m := rePDFDate.FindStringSubmatch(p); m != nil {
return m[1]
}
return ""
}
mtimeOf := func(p string) int64 {
info, err := os.Stat(filepath.Join(careerOpsPath, filepath.FromSlash(p)))
if err != nil {
return 0
}
return info.ModTime().UnixNano()
}
sort.SliceStable(paths, func(i, j int) bool {
di, dj := dateOf(paths[i]), dateOf(paths[j])
if di != dj {
return di > dj
}
return mtimeOf(paths[i]) > mtimeOf(paths[j])
})
}
// kebabCase lowercases s and collapses every non-alphanumeric run into a
// single hyphen: "Monarch Money" → "monarch-money".
func kebabCase(s string) string {
var b strings.Builder
lastHyphen := true // suppress leading hyphen
for _, r := range strings.ToLower(s) {
isAlnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if isAlnum {
b.WriteRune(r)
lastHyphen = false
} else if !lastHyphen {
b.WriteByte('-')
lastHyphen = true
}
}
return strings.TrimRight(b.String(), "-")
}
// ResolveHTML returns the newest output/cv-*{companySlug}*.html for app and
// a companion PDF path derived from it (same base, .pdf extension). Returns
// ("","") when no matching HTML file is found. Used by the D handler to allow
// PDF generation for entries that have a tailored CV HTML but no prior PDF.
func ResolveHTML(careerOpsPath string, app model.CareerApplication) (htmlPath, pdfPath string) {
slug := kebabCase(app.Company)
if slug == "" {
return "", ""
}
globbed, err := filepath.Glob(filepath.Join(careerOpsPath, "output", "cv-*.html"))
if err != nil {
return "", ""
}
var matches []string
for _, p := range globbed {
base := strings.ToLower(filepath.Base(p))
if matchesCompanySlug(base, slug) {
if rel, err := filepath.Rel(careerOpsPath, p); err == nil {
matches = append(matches, filepath.ToSlash(rel))
}
}
}
if len(matches) == 0 {
return "", ""
}
sortPDFsNewestFirst(careerOpsPath, matches)
relHTML := matches[0]
relPDF := strings.TrimSuffix(relHTML, ".html") + ".pdf"
return relHTML, relPDF
}
+173
View File
@@ -0,0 +1,173 @@
package data
import (
"os"
"path/filepath"
"testing"
"github.com/santifer/career-ops/dashboard/internal/model"
)
func writeFixture(t *testing.T, root, rel string, content string) {
t.Helper()
full := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
func TestLoadPDFManifestLaterRowsWin(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, "data/pdf-index.tsv",
"# report\tpdf\thtml\tformat\tdate\n"+
"008\toutput/cv-old.pdf\toutput/cv-old.html\tletter\t2026-06-01\n"+
"008\toutput/cv-new.pdf\toutput/cv-new.html\tletter\t2026-06-05\n"+
"\toutput/cv-orphan.pdf\t\tletter\t2026-06-05\n")
manifest := LoadPDFManifest(root)
if len(manifest) != 1 {
t.Fatalf("expected 1 indexed entry (orphan rows skipped), got %d", len(manifest))
}
entry, ok := manifest["8"] // indexed under the normalized (unpadded) key
if !ok {
t.Fatalf("expected entry for report 008 under normalized key")
}
if entry.PDFPath != "output/cv-new.pdf" {
t.Fatalf("expected later row to win, got %q", entry.PDFPath)
}
if entry.HTMLPath != "output/cv-new.html" {
t.Fatalf("expected html path parsed, got %q", entry.HTMLPath)
}
}
func TestLoadPDFManifestMissingFile(t *testing.T) {
manifest := LoadPDFManifest(t.TempDir())
if len(manifest) != 0 {
t.Fatalf("expected empty manifest for missing file, got %d entries", len(manifest))
}
}
func TestResolvePDFsPrefersManifest(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, "output/cv-jane-doe-acme-2026-06-01.pdf", "pdf")
writeFixture(t, root, "output/cv-jane-doe-acme-exact-2026-06-05.pdf", "pdf")
writeFixture(t, root, "data/pdf-index.tsv",
"012\toutput/cv-jane-doe-acme-exact-2026-06-05.pdf\toutput/cv.html\tletter\t2026-06-05\n")
app := model.CareerApplication{Company: "Acme", ReportNumber: "012"}
got := ResolvePDFs(root, app, LoadPDFManifest(root))
if len(got) != 1 || got[0] != "output/cv-jane-doe-acme-exact-2026-06-05.pdf" {
t.Fatalf("expected single exact manifest match, got %v", got)
}
}
func TestResolvePDFsManifestEntryWithMissingFileFallsBackToGlob(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, "output/cv-jane-doe-acme-2026-06-01.pdf", "pdf")
writeFixture(t, root, "data/pdf-index.tsv",
"012\toutput/cv-deleted.pdf\toutput/cv.html\tletter\t2026-06-05\n")
app := model.CareerApplication{Company: "Acme", ReportNumber: "012"}
got := ResolvePDFs(root, app, LoadPDFManifest(root))
if len(got) != 1 || got[0] != "output/cv-jane-doe-acme-2026-06-01.pdf" {
t.Fatalf("expected glob fallback when manifest file is gone, got %v", got)
}
}
func TestResolvePDFsGlobReturnsAllCompanyVariantsNewestFirst(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, "output/cv-jane-doe-anthropic-2026-06-01.pdf", "pdf")
writeFixture(t, root, "output/cv-jane-doe-anthropic-staff-ui-2026-06-05.pdf", "pdf")
writeFixture(t, root, "output/cv-jane-doe-netflix-2026-06-05.pdf", "pdf")
app := model.CareerApplication{Company: "Anthropic", ReportNumber: "099"}
got := ResolvePDFs(root, app, LoadPDFManifest(root))
if len(got) != 2 {
t.Fatalf("expected 2 anthropic matches, got %v", got)
}
if got[0] != "output/cv-jane-doe-anthropic-staff-ui-2026-06-05.pdf" {
t.Fatalf("expected newest first, got %v", got)
}
}
func TestLookupTracksScrambledTrackerNumbers(t *testing.T) {
root := t.TempDir()
// Real trackers scramble the # column vs the report-link number
// (e.g. row #19 links to report 008) and zero-pad the link form.
writeFixture(t, root, "data/pdf-index.tsv",
"008\toutput/cv-by-link.pdf\toutput/cv.html\tletter\t2026-06-05\n"+
"19\toutput/cv-by-tracker.pdf\toutput/cv.html\tletter\t2026-06-05\n")
manifest := LoadPDFManifest(root)
// ReportNumber "008" matches the "008" row despite zero-padding.
if entry, ok := manifest.Lookup(model.CareerApplication{Number: 99, ReportNumber: "008"}); !ok || entry.PDFPath != "output/cv-by-link.pdf" {
t.Fatalf("expected report-link lookup to win, got %+v (ok=%v)", entry, ok)
}
// Unpadded "8" finds the padded "008" row too.
if entry, ok := manifest.Lookup(model.CareerApplication{ReportNumber: "8"}); !ok || entry.PDFPath != "output/cv-by-link.pdf" {
t.Fatalf("expected unpadded report number to match padded row, got %+v (ok=%v)", entry, ok)
}
// No report link parsed: fall back to the tracker # column.
if entry, ok := manifest.Lookup(model.CareerApplication{Number: 19}); !ok || entry.PDFPath != "output/cv-by-tracker.pdf" {
t.Fatalf("expected tracker-number fallback, got %+v (ok=%v)", entry, ok)
}
// Neither matches: miss.
if _, ok := manifest.Lookup(model.CareerApplication{Number: 42, ReportNumber: "041"}); ok {
t.Fatal("expected lookup miss for unknown numbers")
}
}
func TestResolvePDFsManifestMatchWithMismatchedNumberAndPaddedKey(t *testing.T) {
root := t.TempDir()
writePDF := "output/cv-jane-doe-anthropic-staff-ui-2026-06-05.pdf"
writeFixture(t, root, writePDF, "pdf")
writeFixture(t, root, "output/cv-jane-doe-anthropic-other-2026-06-05.pdf", "pdf")
writeFixture(t, root, "data/pdf-index.tsv",
"008\t"+writePDF+"\toutput/cv.html\tletter\t2026-06-05\n")
// Tracker row #19 → report 008: the manifest must still match exactly
// instead of falling through to the ambiguous company glob.
app := model.CareerApplication{Number: 19, Company: "Anthropic", ReportNumber: "008"}
got := ResolvePDFs(root, app, LoadPDFManifest(root))
if len(got) != 1 || got[0] != writePDF {
t.Fatalf("expected exact manifest match for scrambled numbers, got %v", got)
}
}
func TestResolvePDFsMultiWordCompany(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, "output/cv-jane-doe-monarch-money-2026-06-05.pdf", "pdf")
app := model.CareerApplication{Company: "Monarch Money"}
got := ResolvePDFs(root, app, LoadPDFManifest(root))
if len(got) != 1 {
t.Fatalf("expected multi-word company to match its kebab slug, got %v", got)
}
}
func TestResolvePDFsNoMatch(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, "output/cv-jane-doe-acme-2026-06-05.pdf", "pdf")
app := model.CareerApplication{Company: "Globex"}
if got := ResolvePDFs(root, app, LoadPDFManifest(root)); len(got) != 0 {
t.Fatalf("expected no matches for unrelated company, got %v", got)
}
}
func TestKebabCase(t *testing.T) {
cases := map[string]string{
"Monarch Money": "monarch-money",
"Anthropic": "anthropic",
" O'Brien & Co ": "o-brien-co",
"X": "x",
}
for in, want := range cases {
if got := kebabCase(in); got != want {
t.Errorf("kebabCase(%q) = %q, want %q", in, got, want)
}
}
}