init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user