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 }