init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
module github.com/santifer/career-ops/dashboard
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/muesli/termenv v0.16.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package model
|
||||
|
||||
// CareerApplication represents a single job application from the tracker.
|
||||
type CareerApplication struct {
|
||||
Number int
|
||||
Date string
|
||||
Company string
|
||||
Role string
|
||||
Status string
|
||||
Score float64
|
||||
ScoreRaw string
|
||||
HasPDF bool
|
||||
ReportPath string
|
||||
ReportNumber string
|
||||
Notes string
|
||||
JobURL string // URL of the original job posting
|
||||
// Derived from Notes free-text (see data.deriveNoteFields)
|
||||
Location string // "City, ST" when a US city+state appears in the notes
|
||||
WorkMode string // "Remote" | "Hybrid" | "Full" (onsite), "" when unknown
|
||||
PayRange string // first $-range found in the notes, e.g. "$140-210K"
|
||||
PayMax float64 // top of PayRange in dollars (sort key), 0 when unknown
|
||||
PaySource string // "POSTED" when the JD listed it, "est" for estimates, "" unknown
|
||||
LastContact string // max YYYY-MM-DD found in notes (falls back to applied date)
|
||||
// Enrichment (lazy loaded from report)
|
||||
Archetype string
|
||||
TlDr string
|
||||
Remote string
|
||||
CompEstimate string
|
||||
}
|
||||
|
||||
// PipelineMetrics holds aggregate stats for the pipeline dashboard.
|
||||
type PipelineMetrics struct {
|
||||
Total int
|
||||
ByStatus map[string]int
|
||||
AvgScore float64
|
||||
TopScore float64
|
||||
WithPDF int
|
||||
Actionable int
|
||||
}
|
||||
|
||||
// ProgressMetrics holds job search progress analytics.
|
||||
type ProgressMetrics struct {
|
||||
// Funnel
|
||||
FunnelStages []FunnelStage
|
||||
|
||||
// Score distribution
|
||||
ScoreBuckets []ScoreBucket
|
||||
|
||||
// Timeline (weekly activity)
|
||||
WeeklyActivity []WeekActivity
|
||||
|
||||
// Rates
|
||||
ResponseRate float64 // Responded / Applied
|
||||
InterviewRate float64 // Interview / Applied
|
||||
OfferRate float64 // Offer / Applied
|
||||
|
||||
// Averages
|
||||
AvgScore float64
|
||||
TopScore float64
|
||||
TotalOffers int
|
||||
ActiveApps int // not skip/rejected/discarded
|
||||
}
|
||||
|
||||
// FunnelStage represents one stage of the application funnel.
|
||||
type FunnelStage struct {
|
||||
Label string
|
||||
Count int
|
||||
Pct float64 // percentage of total
|
||||
}
|
||||
|
||||
// ScoreBucket represents a score range and its count.
|
||||
type ScoreBucket struct {
|
||||
Label string // e.g., "4.5-5.0", "4.0-4.4", "3.5-3.9", "3.0-3.4", "<3.0"
|
||||
Count int
|
||||
}
|
||||
|
||||
// WeekActivity represents application activity for a given ISO week.
|
||||
type WeekActivity struct {
|
||||
Week string // e.g., "2026-W14", "2026-W13"
|
||||
Count int
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
func newCatppuccinMocha() Theme {
|
||||
return Theme{
|
||||
// Catppuccin Mocha palette
|
||||
Base: lipgloss.Color("#1e1e2e"),
|
||||
Surface: lipgloss.Color("#313244"),
|
||||
Overlay: lipgloss.Color("#45475a"),
|
||||
Text: lipgloss.Color("#cdd6f4"),
|
||||
Subtext: lipgloss.Color("#a6adc8"),
|
||||
|
||||
// Accents
|
||||
Blue: lipgloss.Color("#89b4fa"),
|
||||
Mauve: lipgloss.Color("#cba6f7"),
|
||||
Green: lipgloss.Color("#a6e3a1"),
|
||||
Yellow: lipgloss.Color("#f9e2af"),
|
||||
Sky: lipgloss.Color("#89dceb"),
|
||||
Peach: lipgloss.Color("#fab387"),
|
||||
Red: lipgloss.Color("#f38ba8"),
|
||||
Pink: lipgloss.Color("#f5c2e7"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
func newCatppuccinLatte() Theme {
|
||||
return Theme{
|
||||
// Catppuccin Latte palette
|
||||
Base: lipgloss.Color("#eff1f5"),
|
||||
Surface: lipgloss.Color("#dce0e8"),
|
||||
Overlay: lipgloss.Color("#9ca0b0"),
|
||||
Text: lipgloss.Color("#4c4f69"),
|
||||
Subtext: lipgloss.Color("#5c5f77"),
|
||||
|
||||
// Accents
|
||||
Blue: lipgloss.Color("#1e66f5"),
|
||||
Mauve: lipgloss.Color("#8839ef"),
|
||||
Green: lipgloss.Color("#40a02b"),
|
||||
Yellow: lipgloss.Color("#df8e1d"),
|
||||
Sky: lipgloss.Color("#04a5e5"),
|
||||
Peach: lipgloss.Color("#fe640b"),
|
||||
Red: lipgloss.Color("#d20f39"),
|
||||
Pink: lipgloss.Color("#ea76cb"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package theme provides the visual theme system for the dashboard.
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
// Theme holds all color definitions for the pipeline dashboard.
|
||||
type Theme struct {
|
||||
// Base colors
|
||||
Base lipgloss.Color
|
||||
Surface lipgloss.Color
|
||||
Overlay lipgloss.Color
|
||||
Text lipgloss.Color
|
||||
Subtext lipgloss.Color
|
||||
|
||||
// Accent colors
|
||||
Blue lipgloss.Color
|
||||
Mauve lipgloss.Color
|
||||
Green lipgloss.Color
|
||||
Yellow lipgloss.Color
|
||||
Sky lipgloss.Color
|
||||
Peach lipgloss.Color
|
||||
Red lipgloss.Color
|
||||
Pink lipgloss.Color
|
||||
}
|
||||
|
||||
// NewTheme creates a theme by name. Use "auto" or "" to detect from terminal background.
|
||||
func NewTheme(name string) Theme {
|
||||
switch name {
|
||||
case "catppuccin-mocha":
|
||||
return newCatppuccinMocha()
|
||||
case "catppuccin-latte":
|
||||
return newCatppuccinLatte()
|
||||
case "auto", "":
|
||||
if termenv.HasDarkBackground() {
|
||||
return newCatppuccinMocha()
|
||||
}
|
||||
return newCatppuccinLatte()
|
||||
default:
|
||||
return newCatppuccinMocha()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,174 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func keyMsg(s string) tea.KeyMsg {
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
||||
}
|
||||
|
||||
func newPDFTestModel(t *testing.T, careerOpsPath string, apps []model.CareerApplication) PipelineModel {
|
||||
t.Helper()
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
apps,
|
||||
model.PipelineMetrics{Total: len(apps)},
|
||||
careerOpsPath,
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.viewMode = "flat"
|
||||
pm.applyFilterAndSort()
|
||||
return pm
|
||||
}
|
||||
|
||||
func writePDFFixture(t *testing.T, root, rel string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("pdf"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFKeyFlashesWhenNoPDFExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("d"))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected no command when no PDF matches")
|
||||
}
|
||||
if updated.flash == "" {
|
||||
t.Fatal("expected a flash notice when no PDF matches")
|
||||
}
|
||||
if updated.pdfPicker {
|
||||
t.Fatal("expected no picker when no PDF matches")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFKeyOpensSingleMatchDirectly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-globex-2026-06-05.pdf")
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("d"))
|
||||
|
||||
if updated.pdfPicker {
|
||||
t.Fatal("expected no picker for a single match")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected an open command for a single match")
|
||||
}
|
||||
msg, ok := cmd().(PipelineOpenPDFMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected PipelineOpenPDFMsg, got %T", cmd())
|
||||
}
|
||||
if !strings.HasSuffix(msg.Path, "cv-jane-doe-globex-2026-06-05.pdf") {
|
||||
t.Fatalf("unexpected PDF path %q", msg.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFKeyOpensNewestForMultipleMatches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Write two PDFs for the same company with distinct dates so ordering is predictable.
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-anthropic-2026-06-05.pdf")
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-anthropic-2026-06-10.pdf")
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Anthropic", Role: "Staff UI Engineer", Status: "Evaluated", Score: 4.6},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("d"))
|
||||
|
||||
// Multiple candidates → open the newest one immediately, no picker.
|
||||
if updated.pdfPicker {
|
||||
t.Fatal("expected no picker — newest match should be opened directly")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected an open command for the newest match")
|
||||
}
|
||||
msg, ok := cmd().(PipelineOpenPDFMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected PipelineOpenPDFMsg, got %T", cmd())
|
||||
}
|
||||
if !strings.HasSuffix(msg.Path, "cv-jane-doe-anthropic-2026-06-10.pdf") {
|
||||
t.Fatalf("expected newest PDF to be opened, got %q", msg.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateKeyFlashesWithoutManifestEntry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0, ReportNumber: "001"},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("D"))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected no command without a manifest entry")
|
||||
}
|
||||
if updated.flash == "" {
|
||||
t.Fatal("expected a flash notice without a manifest entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateKeyEmitsGenerateMsgFromManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-globex.html")
|
||||
writePDFFixture(t, root, "data/pdf-index.tsv") // placeholder, overwritten below
|
||||
manifest := "001\toutput/cv-jane-doe-globex-2026-06-05.pdf\toutput/cv-jane-doe-globex.html\tletter\t2026-06-05\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "data", "pdf-index.tsv"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0, ReportNumber: "001"},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("D"))
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected a generate command")
|
||||
}
|
||||
if updated.flash == "" {
|
||||
t.Fatal("expected an in-progress flash while regenerating")
|
||||
}
|
||||
msg, ok := cmd().(PipelineGeneratePDFMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected PipelineGeneratePDFMsg, got %T", cmd())
|
||||
}
|
||||
if msg.ReportNumber != "001" || msg.HTMLPath != "output/cv-jane-doe-globex.html" || msg.Format != "letter" {
|
||||
t.Fatalf("unexpected generate request: %+v", msg)
|
||||
}
|
||||
|
||||
// Outcome message updates the flash.
|
||||
done, _ := updated.Update(PipelinePDFGeneratedMsg{Path: "/abs/cv.pdf"})
|
||||
if !strings.Contains(done.flash, "cv.pdf") {
|
||||
t.Fatalf("expected success flash to name the PDF, got %q", done.flash)
|
||||
}
|
||||
failed, _ := updated.Update(PipelinePDFGeneratedMsg{Err: "node not found"})
|
||||
if !strings.Contains(failed.flash, "node not found") {
|
||||
t.Fatalf("expected failure flash to carry the error, got %q", failed.flash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func tabIndexForFilter(t *testing.T, filter string) int {
|
||||
t.Helper()
|
||||
|
||||
for i, tab := range pipelineTabs {
|
||||
if tab.filter == filter {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected pipeline tabs to include filter %q", filter)
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestWithReloadedDataPreservesStateAndSelection(t *testing.T) {
|
||||
initialApps := []model.CareerApplication{
|
||||
{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Evaluated",
|
||||
Score: 4.2,
|
||||
ReportPath: "reports/001-acme.md",
|
||||
},
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
initialApps,
|
||||
model.PipelineMetrics{Total: len(initialApps)},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.sortMode = sortCompany
|
||||
pm.activeTab = 0
|
||||
pm.viewMode = "flat"
|
||||
pm.applyFilterAndSort()
|
||||
pm.cursor = 1
|
||||
pm.reportCache["reports/002-beta.md"] = reportSummary{tldr: "cached"}
|
||||
|
||||
refreshedApps := []model.CareerApplication{
|
||||
initialApps[0],
|
||||
initialApps[1],
|
||||
{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Interview",
|
||||
Score: 4.8,
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
},
|
||||
}
|
||||
|
||||
reloaded := pm.WithReloadedData(refreshedApps, model.PipelineMetrics{Total: len(refreshedApps)})
|
||||
|
||||
if reloaded.sortMode != sortCompany {
|
||||
t.Fatalf("expected sort mode %q, got %q", sortCompany, reloaded.sortMode)
|
||||
}
|
||||
if reloaded.viewMode != "flat" {
|
||||
t.Fatalf("expected view mode to stay flat, got %q", reloaded.viewMode)
|
||||
}
|
||||
if got := len(reloaded.filtered); got != 3 {
|
||||
t.Fatalf("expected 3 filtered apps after refresh, got %d", got)
|
||||
}
|
||||
if app, ok := reloaded.CurrentApp(); !ok || app.ReportPath != "reports/002-beta.md" {
|
||||
t.Fatalf("expected selection to stay on beta app, got %+v (ok=%v)", app, ok)
|
||||
}
|
||||
if reloaded.reportCache["reports/002-beta.md"].tldr != "cached" {
|
||||
t.Fatal("expected cached report summaries to survive refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAppLineIncludesDateColumn(t *testing.T) {
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
nil,
|
||||
model.PipelineMetrics{},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
|
||||
line := pm.renderAppLine(model.CareerApplication{
|
||||
Number: 42,
|
||||
Date: "2026-04-13",
|
||||
Company: "Anthropic",
|
||||
Role: "Forward Deployed Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.5,
|
||||
}, false)
|
||||
|
||||
if !strings.Contains(line, "2026-04-13") {
|
||||
t.Fatalf("expected rendered line to include date column, got %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "#42") {
|
||||
t.Fatalf("expected rendered line to include tracker number marker, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFiltersByCompanyRoleAndNotes(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6, Notes: "payments infra"},
|
||||
{Company: "Anthropic", Role: "AI Safety Engineer", Status: "Applied", Score: 4.8, Notes: "policy work"},
|
||||
{Company: "Acme Corp", Role: "Senior PM, Voice AI", Status: "Evaluated", Score: 4.2, Notes: "Series B in Madrid"},
|
||||
{Company: "Globex", Role: "Platform Engineer", Status: "Applied", Score: 3.9, Notes: "remote-first"},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.activeTab = tabIndexForFilter(t, filterAll)
|
||||
|
||||
// Match by company substring (case-insensitive).
|
||||
pm.searchQuery = "stripe"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Stripe" {
|
||||
t.Fatalf("expected 1 match for 'stripe', got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Match by role substring.
|
||||
pm.searchQuery = "voice ai"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Acme Corp" {
|
||||
t.Fatalf("expected 1 match for 'voice ai', got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Match by notes substring.
|
||||
pm.searchQuery = "madrid"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Acme Corp" {
|
||||
t.Fatalf("expected 1 match for notes 'madrid', got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Empty query restores everything.
|
||||
pm.searchQuery = ""
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != len(apps) {
|
||||
t.Fatalf("expected empty query to restore all rows, got %d/%d", len(pm.filtered), len(apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchComposesWithActiveTab(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Stripe", Role: "Frontend Engineer", Status: "Applied", Score: 4.5},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Applied", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.activeTab = tabIndexForFilter(t, filterApplied)
|
||||
pm.searchQuery = "stripe"
|
||||
pm.applyFilterAndSort()
|
||||
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Role != "Frontend Engineer" {
|
||||
t.Fatalf("expected applied+stripe to leave only Frontend Engineer, got %+v", pm.filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIsCaseInsensitive(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
for _, q := range []string{"anthropic", "ANTHROPIC", "AnThRoPiC"} {
|
||||
pm.searchQuery = q
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 {
|
||||
t.Fatalf("expected case-insensitive match for %q, got %d rows", q, len(pm.filtered))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEnterCommitsAndEscClearsCommittedQuery(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
|
||||
// Open input and type "stripe".
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
if !pm.searchInput {
|
||||
t.Fatal("expected `/` to open search input")
|
||||
}
|
||||
for _, r := range "stripe" {
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
if pm.searchQuery != "stripe" {
|
||||
t.Fatalf("expected query to live-update to 'stripe', got %q", pm.searchQuery)
|
||||
}
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Stripe" {
|
||||
t.Fatalf("expected live filter to leave only Stripe, got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Enter commits — input closes, query stays.
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if pm.searchInput {
|
||||
t.Fatal("expected Enter to close input")
|
||||
}
|
||||
if pm.searchQuery != "stripe" {
|
||||
t.Fatalf("expected Enter to keep committed query, got %q", pm.searchQuery)
|
||||
}
|
||||
|
||||
// Esc on a committed query clears the search and restores the full list.
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if pm.searchQuery != "" {
|
||||
t.Fatalf("expected Esc to clear committed query, got %q", pm.searchQuery)
|
||||
}
|
||||
if len(pm.filtered) != len(apps) {
|
||||
t.Fatalf("expected Esc to restore full list, got %d/%d", len(pm.filtered), len(apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEscInInputCancelsAndClears(t *testing.T) {
|
||||
// Use multiple rows so the test catches a regression where Esc clears the query
|
||||
// but forgets to re-apply the filter — the visible count would stay at 1
|
||||
// otherwise even though the underlying state went stale.
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Globex", Role: "Platform Engineer", Status: "Evaluated", Score: 4.0},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.searchInput = true
|
||||
pm.searchQuery = "stri"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 {
|
||||
t.Fatalf("setup expected 1 row matching 'stri', got %d", len(pm.filtered))
|
||||
}
|
||||
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if pm.searchInput {
|
||||
t.Fatal("expected Esc in input mode to close input")
|
||||
}
|
||||
if pm.searchQuery != "" {
|
||||
t.Fatalf("expected Esc in input mode to clear in-progress query, got %q", pm.searchQuery)
|
||||
}
|
||||
if len(pm.filtered) != len(apps) {
|
||||
t.Fatalf("expected Esc to re-expand filtered list to %d rows, got %d", len(apps), len(pm.filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchResetsCursorOnQueryChange(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Acme", Role: "Backend Engineer", Status: "Evaluated", Score: 4.0},
|
||||
{Company: "Beta", Role: "Frontend Engineer", Status: "Evaluated", Score: 4.1},
|
||||
{Company: "Gamma", Role: "AI Engineer", Status: "Evaluated", Score: 4.2},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.cursor = 2
|
||||
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
|
||||
|
||||
if pm.cursor != 0 {
|
||||
t.Fatalf("expected cursor to reset to 0 on query change, got %d", pm.cursor)
|
||||
}
|
||||
if pm.scrollOffset != 0 {
|
||||
t.Fatalf("expected scrollOffset to reset to 0 on query change, got %d", pm.scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchStatePreservedAcrossReload(t *testing.T) {
|
||||
initial := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Acme", Role: "AI", Status: "Evaluated", Score: 4.0},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), initial, model.PipelineMetrics{Total: len(initial)}, "..", 120, 40)
|
||||
pm.searchQuery = "stripe"
|
||||
pm.applyFilterAndSort()
|
||||
|
||||
refreshed := append([]model.CareerApplication{}, initial...)
|
||||
refreshed = append(refreshed, model.CareerApplication{Company: "Globex", Role: "Platform", Status: "Applied", Score: 4.3})
|
||||
|
||||
reloaded := pm.WithReloadedData(refreshed, model.PipelineMetrics{Total: len(refreshed)})
|
||||
|
||||
if reloaded.searchQuery != "stripe" {
|
||||
t.Fatalf("expected refresh to preserve search query, got %q", reloaded.searchQuery)
|
||||
}
|
||||
if len(reloaded.filtered) != 1 || reloaded.filtered[0].Company != "Stripe" {
|
||||
t.Fatalf("expected refresh+search to keep filter applied, got %+v", reloaded.filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedAndDiscardedTabsFilterCorrectly(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Rejected",
|
||||
Score: 3.4,
|
||||
ReportPath: "reports/001-acme.md",
|
||||
},
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Discarded",
|
||||
Score: 2.1,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
apps,
|
||||
model.PipelineMetrics{Total: len(apps)},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
|
||||
pm.activeTab = tabIndexForFilter(t, filterRejected)
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Status != "Rejected" {
|
||||
t.Fatalf("expected rejected tab to isolate rejected rows, got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
pm.activeTab = tabIndexForFilter(t, filterDiscarded)
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Status != "Discarded" {
|
||||
t.Fatalf("expected discarded tab to isolate discarded rows, got %+v", pm.filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: with no committed search query, Esc must NOT close the screen.
|
||||
// The help bar advertises only `q quit`, so Esc quitting silently was a bug
|
||||
// that surfaced as accidental exits when users hit Esc to "back out" of the UI.
|
||||
func TestEscWithoutQueryIsNoOp(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
if pm.searchQuery != "" {
|
||||
t.Fatalf("setup expected empty search query, got %q", pm.searchQuery)
|
||||
}
|
||||
|
||||
pm, cmd := pm.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if cmd != nil {
|
||||
// PipelineClosedMsg used to fire here; ensure it doesn't anymore.
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineClosedMsg); ok {
|
||||
t.Fatalf("expected Esc with no query to be a no-op, got PipelineClosedMsg")
|
||||
}
|
||||
t.Fatalf("expected Esc with no query to return nil cmd, got %T", msg)
|
||||
}
|
||||
}
|
||||
if pm.searchInput {
|
||||
t.Fatal("Esc with no query should not toggle searchInput")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: typing during search input must not synchronously fan out to
|
||||
// loadCurrentReport. Reading reports per keystroke caused visible UI lag, so
|
||||
// the load is deferred to commit (Enter) / cancel (Esc) instead.
|
||||
func TestSearchTypingDoesNotLoadReports(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6, ReportPath: "reports/001-stripe.md"},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8, ReportPath: "reports/002-anthropic.md"},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
if !pm.searchInput {
|
||||
t.Fatal("expected `/` to open search input")
|
||||
}
|
||||
|
||||
// Typing must not trigger PipelineLoadReportMsg.
|
||||
for _, r := range "stri" {
|
||||
var cmd tea.Cmd
|
||||
pm, cmd = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
if cmd != nil {
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineLoadReportMsg); ok {
|
||||
t.Fatalf("typing rune %q should not emit PipelineLoadReportMsg", string(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace must not trigger PipelineLoadReportMsg either.
|
||||
pm, cmd := pm.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
if cmd != nil {
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineLoadReportMsg); ok {
|
||||
t.Fatal("Backspace during search input should not emit PipelineLoadReportMsg")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+U must not trigger PipelineLoadReportMsg either.
|
||||
pm, cmd = pm.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
|
||||
if cmd != nil {
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineLoadReportMsg); ok {
|
||||
t.Fatal("Ctrl+U during search input should not emit PipelineLoadReportMsg")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func previewModelWith(t *testing.T, app model.CareerApplication) PipelineModel {
|
||||
t.Helper()
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
[]model.CareerApplication{app},
|
||||
model.PipelineMetrics{Total: 1},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.applyFilterAndSort()
|
||||
pm.cursor = 0
|
||||
return pm
|
||||
}
|
||||
|
||||
func TestPreviewKeepsDiscardReasonWhenTlDrIsCached(t *testing.T) {
|
||||
app := model.CareerApplication{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Descartado 2026-03-12",
|
||||
Notes: "took too long to respond",
|
||||
ReportPath: "reports/001-acme.md",
|
||||
}
|
||||
pm := previewModelWith(t, app)
|
||||
pm.reportCache[app.ReportPath] = reportSummary{tldr: "great team, fast pace"}
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if !strings.Contains(preview, "great team, fast pace") {
|
||||
t.Fatalf("expected preview to keep the cached TL;DR, got %q", preview)
|
||||
}
|
||||
// Regression for #787: before the Outcome line, a cached TL;DR replaced the
|
||||
// notes entirely and the discard reason disappeared from the preview.
|
||||
if !strings.Contains(preview, "took too long to respond") {
|
||||
t.Fatalf("expected preview to keep the discard reason alongside the TL;DR, got %q", preview)
|
||||
}
|
||||
if !strings.Contains(preview, "Descartado 2026-03-12") {
|
||||
t.Fatalf("expected preview to show the closing status, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewOutcomeShownWithoutReportSummary(t *testing.T) {
|
||||
pm := previewModelWith(t, model.CareerApplication{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "SKIP",
|
||||
Notes: "geo blocker",
|
||||
})
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if !strings.Contains(preview, "Outcome:") || !strings.Contains(preview, "geo blocker") {
|
||||
t.Fatalf("expected outcome line with notes for skipped app, got %q", preview)
|
||||
}
|
||||
if strings.Count(preview, "geo blocker") != 1 {
|
||||
t.Fatalf("expected notes to appear exactly once, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewOutcomeOmittedForActiveApps(t *testing.T) {
|
||||
app := model.CareerApplication{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Applied 2026-04-01",
|
||||
Notes: "warm intro via referral",
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
}
|
||||
pm := previewModelWith(t, app)
|
||||
pm.reportCache[app.ReportPath] = reportSummary{tldr: "strong fit"}
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if strings.Contains(preview, "Outcome:") {
|
||||
t.Fatalf("expected no outcome line for an active app, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewOutcomeForStatusWithoutNotes(t *testing.T) {
|
||||
pm := previewModelWith(t, model.CareerApplication{
|
||||
Company: "Delta",
|
||||
Role: "SRE",
|
||||
Status: "**Rejected** 2026-05-02",
|
||||
})
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if !strings.Contains(preview, "Rejected 2026-05-02") {
|
||||
t.Fatalf("expected outcome to show the bare closing status, got %q", preview)
|
||||
}
|
||||
if strings.Contains(preview, "Loading preview...") {
|
||||
t.Fatalf("expected outcome line to replace the loading placeholder, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithReloadedDataPreservesCursorWhenAppRemoved(t *testing.T) {
|
||||
initialApps := []model.CareerApplication{
|
||||
{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.2,
|
||||
ReportPath: "reports/001-acme.md",
|
||||
},
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.8,
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
initialApps,
|
||||
model.PipelineMetrics{Total: len(initialApps)},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.activeTab = tabIndexForFilter(t, filterApplied)
|
||||
pm.applyFilterAndSort()
|
||||
pm.cursor = 1
|
||||
|
||||
refreshedApps := []model.CareerApplication{
|
||||
initialApps[0],
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Rejected", // Changed!
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
initialApps[2],
|
||||
}
|
||||
|
||||
reloaded := pm.WithReloadedData(refreshedApps, model.PipelineMetrics{Total: len(refreshedApps)})
|
||||
|
||||
if got := len(reloaded.filtered); got != 2 {
|
||||
t.Fatalf("expected 2 filtered apps after refresh, got %d", got)
|
||||
}
|
||||
if reloaded.cursor < 0 || reloaded.cursor >= len(reloaded.filtered) {
|
||||
t.Fatalf("expected cursor to be within [0, %d], got %d", len(reloaded.filtered)-1, reloaded.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
// ProgressClosedMsg is emitted when the progress screen is dismissed.
|
||||
type ProgressClosedMsg struct{}
|
||||
|
||||
// ProgressModel implements the progress analytics screen.
|
||||
type ProgressModel struct {
|
||||
metrics model.ProgressMetrics
|
||||
scrollOffset int
|
||||
width int
|
||||
height int
|
||||
theme theme.Theme
|
||||
}
|
||||
|
||||
// NewProgressModel creates a new progress screen.
|
||||
func NewProgressModel(t theme.Theme, metrics model.ProgressMetrics, width, height int) ProgressModel {
|
||||
return ProgressModel{
|
||||
metrics: metrics,
|
||||
width: width,
|
||||
height: height,
|
||||
theme: t,
|
||||
}
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m ProgressModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resize updates dimensions.
|
||||
func (m *ProgressModel) Resize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// Update handles input for the progress screen.
|
||||
func (m ProgressModel) Update(msg tea.Msg) (ProgressModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
return m, func() tea.Msg { return ProgressClosedMsg{} }
|
||||
case "down", "j":
|
||||
m.scrollOffset++
|
||||
case "up", "k":
|
||||
if m.scrollOffset > 0 {
|
||||
m.scrollOffset--
|
||||
}
|
||||
case "pgdown", "ctrl+d":
|
||||
m.scrollOffset += m.height / 2
|
||||
case "pgup", "ctrl+u":
|
||||
m.scrollOffset -= m.height / 2
|
||||
if m.scrollOffset < 0 {
|
||||
m.scrollOffset = 0
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the progress screen.
|
||||
func (m ProgressModel) View() string {
|
||||
header := m.renderHeader()
|
||||
funnel := m.renderFunnel()
|
||||
scores := m.renderScoreDistribution()
|
||||
rates := m.renderRates()
|
||||
weekly := m.renderWeeklyActivity()
|
||||
help := m.renderHelp()
|
||||
|
||||
// Combine panels
|
||||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||
funnel,
|
||||
"",
|
||||
scores,
|
||||
"",
|
||||
rates,
|
||||
"",
|
||||
weekly,
|
||||
)
|
||||
|
||||
// Apply scroll
|
||||
bodyLines := strings.Split(body, "\n")
|
||||
offset := m.scrollOffset
|
||||
if offset >= len(bodyLines) {
|
||||
offset = len(bodyLines) - 1
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > 0 {
|
||||
bodyLines = bodyLines[offset:]
|
||||
}
|
||||
|
||||
// Clamp to available height
|
||||
availHeight := m.height - 4 // header + help + padding
|
||||
if availHeight < 3 {
|
||||
availHeight = 3
|
||||
}
|
||||
if len(bodyLines) > availHeight {
|
||||
bodyLines = bodyLines[:availHeight]
|
||||
}
|
||||
|
||||
body = strings.Join(bodyLines, "\n")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body, help)
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderHeader() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(m.theme.Text).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 2)
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Mauve).Render("SEARCH PROGRESS")
|
||||
|
||||
right := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
total := len(m.metrics.FunnelStages)
|
||||
totalCount := 0
|
||||
if total > 0 {
|
||||
totalCount = m.metrics.FunnelStages[0].Count
|
||||
}
|
||||
info := right.Render(fmt.Sprintf("%d evaluated | %.1f avg score", totalCount, m.metrics.AvgScore))
|
||||
|
||||
gap := m.width - lipgloss.Width(title) - lipgloss.Width(info) - 4
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return style.Render(title + strings.Repeat(" ", gap) + info)
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderFunnel() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Pipeline Funnel")))
|
||||
|
||||
if len(m.metrics.FunnelStages) == 0 {
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
lines = append(lines, padStyle.Render(dimStyle.Render("No data")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Find max count for bar scaling
|
||||
maxCount := 0
|
||||
for _, s := range m.metrics.FunnelStages {
|
||||
if s.Count > maxCount {
|
||||
maxCount = s.Count
|
||||
}
|
||||
}
|
||||
|
||||
labelW := 10
|
||||
barMaxW := m.width - labelW - 20 // room for label, count, pct
|
||||
if barMaxW < 10 {
|
||||
barMaxW = 10
|
||||
}
|
||||
|
||||
// Colors for funnel stages (gradient from cool to warm)
|
||||
stageColors := []lipgloss.Color{
|
||||
m.theme.Blue,
|
||||
m.theme.Sky,
|
||||
m.theme.Green,
|
||||
m.theme.Yellow,
|
||||
m.theme.Peach,
|
||||
}
|
||||
|
||||
for i, stage := range m.metrics.FunnelStages {
|
||||
barW := 0
|
||||
if maxCount > 0 {
|
||||
barW = stage.Count * barMaxW / maxCount
|
||||
}
|
||||
if barW < 1 && stage.Count > 0 {
|
||||
barW = 1
|
||||
}
|
||||
|
||||
color := m.theme.Text
|
||||
if i < len(stageColors) {
|
||||
color = stageColors[i]
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().Foreground(color)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Width(labelW)
|
||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
||||
label := labelStyle.Render(stage.Label)
|
||||
|
||||
pctStr := ""
|
||||
if i > 0 {
|
||||
pctStr = fmt.Sprintf(" (%.0f%%)", stage.Pct)
|
||||
}
|
||||
count := countStyle.Render(fmt.Sprintf(" %d%s", stage.Count, pctStr))
|
||||
|
||||
lines = append(lines, padStyle.Render(label+bar+count))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderScoreDistribution() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Score Distribution")))
|
||||
|
||||
if len(m.metrics.ScoreBuckets) == 0 {
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
lines = append(lines, padStyle.Render(dimStyle.Render("No data")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Find max count for bar scaling
|
||||
maxCount := 0
|
||||
for _, b := range m.metrics.ScoreBuckets {
|
||||
if b.Count > maxCount {
|
||||
maxCount = b.Count
|
||||
}
|
||||
}
|
||||
|
||||
labelW := 8
|
||||
barMaxW := m.width - labelW - 14
|
||||
if barMaxW < 10 {
|
||||
barMaxW = 10
|
||||
}
|
||||
|
||||
// Colors for score ranges (green to red)
|
||||
bucketColors := []lipgloss.Color{
|
||||
m.theme.Green,
|
||||
m.theme.Green,
|
||||
m.theme.Yellow,
|
||||
m.theme.Peach,
|
||||
m.theme.Red,
|
||||
}
|
||||
|
||||
for i, bucket := range m.metrics.ScoreBuckets {
|
||||
barW := 0
|
||||
if maxCount > 0 {
|
||||
barW = bucket.Count * barMaxW / maxCount
|
||||
}
|
||||
if barW < 1 && bucket.Count > 0 {
|
||||
barW = 1
|
||||
}
|
||||
|
||||
color := m.theme.Text
|
||||
if i < len(bucketColors) {
|
||||
color = bucketColors[i]
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().Foreground(color)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Width(labelW)
|
||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
||||
label := labelStyle.Render(bucket.Label)
|
||||
count := countStyle.Render(fmt.Sprintf(" %d", bucket.Count))
|
||||
|
||||
lines = append(lines, padStyle.Render(label+bar+count))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderRates() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Conversion Rates")))
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text)
|
||||
valueStyle := lipgloss.NewStyle().Bold(true)
|
||||
sepStyle := lipgloss.NewStyle().Foreground(m.theme.Overlay)
|
||||
|
||||
responseColor := m.rateColor(m.metrics.ResponseRate)
|
||||
interviewColor := m.rateColor(m.metrics.InterviewRate)
|
||||
offerColor := m.rateColor(m.metrics.OfferRate)
|
||||
|
||||
sep := sepStyle.Render(" | ")
|
||||
|
||||
rates := labelStyle.Render("Response Rate: ") +
|
||||
valueStyle.Foreground(responseColor).Render(fmt.Sprintf("%.1f%%", m.metrics.ResponseRate)) +
|
||||
sep +
|
||||
labelStyle.Render("Interview Rate: ") +
|
||||
valueStyle.Foreground(interviewColor).Render(fmt.Sprintf("%.1f%%", m.metrics.InterviewRate)) +
|
||||
sep +
|
||||
labelStyle.Render("Offer Rate: ") +
|
||||
valueStyle.Foreground(offerColor).Render(fmt.Sprintf("%.1f%%", m.metrics.OfferRate))
|
||||
|
||||
lines = append(lines, padStyle.Render(rates))
|
||||
|
||||
// Active summary
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
activeInfo := dimStyle.Render(fmt.Sprintf(
|
||||
"%d active applications | %d total offers",
|
||||
m.metrics.ActiveApps, m.metrics.TotalOffers,
|
||||
))
|
||||
lines = append(lines, padStyle.Render(activeInfo))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderWeeklyActivity() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Weekly Activity")))
|
||||
|
||||
if len(m.metrics.WeeklyActivity) == 0 {
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
lines = append(lines, padStyle.Render(dimStyle.Render("No data")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Find max count for bar scaling
|
||||
maxCount := 0
|
||||
for _, w := range m.metrics.WeeklyActivity {
|
||||
if w.Count > maxCount {
|
||||
maxCount = w.Count
|
||||
}
|
||||
}
|
||||
|
||||
labelW := 10
|
||||
barMaxW := m.width - labelW - 12
|
||||
if barMaxW < 10 {
|
||||
barMaxW = 10
|
||||
}
|
||||
|
||||
for _, week := range m.metrics.WeeklyActivity {
|
||||
barW := 0
|
||||
if maxCount > 0 {
|
||||
barW = week.Count * barMaxW / maxCount
|
||||
}
|
||||
if barW < 1 && week.Count > 0 {
|
||||
barW = 1
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().Foreground(m.theme.Blue)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Width(labelW)
|
||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
// Show short week label (e.g., "W14" from "2026-W14")
|
||||
shortWeek := week.Week
|
||||
if idx := strings.Index(shortWeek, "-"); idx >= 0 {
|
||||
shortWeek = shortWeek[idx+1:]
|
||||
}
|
||||
|
||||
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
||||
label := labelStyle.Render(shortWeek)
|
||||
count := countStyle.Render(fmt.Sprintf(" %d", week.Count))
|
||||
|
||||
lines = append(lines, padStyle.Render(label+bar+count))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderHelp() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(m.theme.Subtext).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 1)
|
||||
|
||||
keyStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Text)
|
||||
descStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
brand := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render("career-ops by santifer.io")
|
||||
|
||||
keys := keyStyle.Render("\u2191\u2193") + descStyle.Render(" scroll ") +
|
||||
keyStyle.Render("PgUp/Dn") + descStyle.Render(" page ") +
|
||||
keyStyle.Render("Esc") + descStyle.Render(" back")
|
||||
|
||||
gap := m.width - lipgloss.Width(keys) - lipgloss.Width(brand) - 2
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return style.Render(keys + strings.Repeat(" ", gap) + brand)
|
||||
}
|
||||
|
||||
// rateColor returns a color based on the rate value.
|
||||
func (m ProgressModel) rateColor(rate float64) lipgloss.Color {
|
||||
switch {
|
||||
case rate >= 30:
|
||||
return m.theme.Green
|
||||
case rate >= 15:
|
||||
return m.theme.Yellow
|
||||
case rate >= 5:
|
||||
return m.theme.Peach
|
||||
default:
|
||||
return m.theme.Red
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func TestSortCycleIncludesNewColumns(t *testing.T) {
|
||||
want := map[string]bool{sortLocation: false, sortPay: false, sortLast: false}
|
||||
for _, s := range sortCycle {
|
||||
if _, ok := want[s]; ok {
|
||||
want[s] = true
|
||||
}
|
||||
}
|
||||
for mode, found := range want {
|
||||
if !found {
|
||||
t.Errorf("sort cycle is missing %q", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByPayLocationAndLastContact(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "LowPay", Status: "Applied", PayMax: 150_000, WorkMode: "Full", Location: "Austin, TX", LastContact: "2026-06-01"},
|
||||
{Company: "NoPay", Status: "Applied", PayMax: 0, WorkMode: "", LastContact: ""},
|
||||
{Company: "HighPay", Status: "Applied", PayMax: 250_000, WorkMode: "Hybrid", Location: "Charlotte, NC", LastContact: "2026-06-05"},
|
||||
{Company: "MidPay", Status: "Applied", PayMax: 200_000, WorkMode: "Remote", LastContact: "2026-06-03"},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.viewMode = "flat"
|
||||
|
||||
pm.sortMode = sortPay
|
||||
pm.applyFilterAndSort()
|
||||
if pm.filtered[0].Company != "HighPay" || pm.filtered[len(pm.filtered)-1].Company != "NoPay" {
|
||||
t.Fatalf("pay sort: expected HighPay first and NoPay last, got %s..%s",
|
||||
pm.filtered[0].Company, pm.filtered[len(pm.filtered)-1].Company)
|
||||
}
|
||||
|
||||
pm.sortMode = sortLocation
|
||||
pm.applyFilterAndSort()
|
||||
// Remote-first ordering: Remote, Hybrid, Full, unknown.
|
||||
wantOrder := []string{"MidPay", "HighPay", "LowPay", "NoPay"}
|
||||
for i, w := range wantOrder {
|
||||
if pm.filtered[i].Company != w {
|
||||
t.Fatalf("location sort: position %d = %s, want %s", i, pm.filtered[i].Company, w)
|
||||
}
|
||||
}
|
||||
|
||||
pm.sortMode = sortLast
|
||||
pm.applyFilterAndSort()
|
||||
if pm.filtered[0].Company != "HighPay" || pm.filtered[len(pm.filtered)-1].Company != "NoPay" {
|
||||
t.Fatalf("last-contact sort: expected HighPay first and NoPay last, got %s..%s",
|
||||
pm.filtered[0].Company, pm.filtered[len(pm.filtered)-1].Company)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatTimeAgo(t *testing.T) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
threeDaysAgo := time.Now().AddDate(0, 0, -3).Format("2006-01-02")
|
||||
|
||||
// Day-granular dates never fabricate sub-day hours: same day is "today".
|
||||
if got := formatTimeAgo(today); got != "today" {
|
||||
t.Errorf("today should render as \"today\", got %q", got)
|
||||
}
|
||||
if got := formatTimeAgo(yesterday); got != "yesterday" {
|
||||
t.Errorf("one day ago should render as \"yesterday\", got %q", got)
|
||||
}
|
||||
if got := formatTimeAgo(threeDaysAgo); got != "3d ago" {
|
||||
t.Errorf("three days ago should render as \"3d ago\", got %q", got)
|
||||
}
|
||||
|
||||
// Future dates clamp to "today" instead of going negative.
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
if got := formatTimeAgo(tomorrow); got != "today" {
|
||||
t.Errorf("future date should clamp to \"today\", got %q", got)
|
||||
}
|
||||
|
||||
// Non-dates pass through untouched.
|
||||
if got := formatTimeAgo("—"); got != "—" {
|
||||
t.Errorf("non-date should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/data"
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
// ViewerClosedMsg is emitted when the viewer is dismissed.
|
||||
type ViewerClosedMsg struct{}
|
||||
|
||||
// ViewerOpenCoverLetterMsg is emitted when the user requests to open the cover letter PDF.
|
||||
type ViewerOpenCoverLetterMsg struct{ Path string }
|
||||
|
||||
// ViewerUpdateStatusMsg is emitted when a status update is requested from the viewer.
|
||||
type ViewerUpdateStatusMsg struct {
|
||||
App model.CareerApplication
|
||||
NewStatus string
|
||||
}
|
||||
|
||||
// ViewerModel implements an integrated file viewer screen.
|
||||
type ViewerModel struct {
|
||||
lines []string
|
||||
renderedLines []string
|
||||
title string
|
||||
scrollOffset int
|
||||
width int
|
||||
height int
|
||||
theme theme.Theme
|
||||
app model.CareerApplication
|
||||
careerOpsPath string
|
||||
coverLetterPath string
|
||||
statusPicker bool
|
||||
statusCursor int
|
||||
}
|
||||
|
||||
// NewViewerModel creates a new file viewer for the given path.
|
||||
func NewViewerModel(t theme.Theme, careerOpsPath, path, title string, width, height int, app model.CareerApplication) ViewerModel {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
content = []byte("Error reading file: " + err.Error())
|
||||
}
|
||||
|
||||
var lines []string
|
||||
if len(content) > 0 {
|
||||
lines = strings.Split(string(content), "\n")
|
||||
}
|
||||
|
||||
m := ViewerModel{
|
||||
lines: lines,
|
||||
title: title,
|
||||
width: width,
|
||||
height: height,
|
||||
theme: t,
|
||||
app: app,
|
||||
careerOpsPath: careerOpsPath,
|
||||
coverLetterPath: parseCoverLetterPath(lines, careerOpsPath),
|
||||
}
|
||||
m.rebuildRender()
|
||||
return m
|
||||
}
|
||||
|
||||
// parseCoverLetterPath scans the report lines for a "PDF generated: output/..." line
|
||||
// inside a "## Cover Letter Draft" section and returns the relative path if the file exists.
|
||||
func parseCoverLetterPath(lines []string, careerOpsPath string) string {
|
||||
inCoverSection := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "## Cover Letter Draft") {
|
||||
inCoverSection = true
|
||||
continue
|
||||
}
|
||||
if inCoverSection && strings.HasPrefix(trimmed, "## ") {
|
||||
break
|
||||
}
|
||||
if inCoverSection {
|
||||
if m := reCoverLetterPDF.FindStringSubmatch(line); m != nil {
|
||||
relPath := m[1]
|
||||
abs := filepath.Join(careerOpsPath, filepath.FromSlash(relPath))
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return relPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rebuildRender recomputes renderedLines from raw lines using the current width.
|
||||
func (m *ViewerModel) rebuildRender() {
|
||||
m.renderedLines = m.renderAll()
|
||||
m.clampScrollOffset()
|
||||
}
|
||||
|
||||
func (m *ViewerModel) clampScrollOffset() {
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if m.scrollOffset > maxScroll {
|
||||
m.scrollOffset = maxScroll
|
||||
}
|
||||
if m.scrollOffset < 0 {
|
||||
m.scrollOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m ViewerModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ViewerModel) Resize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.rebuildRender()
|
||||
}
|
||||
|
||||
func (m ViewerModel) Update(msg tea.Msg) (ViewerModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if m.statusPicker {
|
||||
return m.handleStatusPicker(msg)
|
||||
}
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
return m, func() tea.Msg { return ViewerClosedMsg{} }
|
||||
|
||||
case "c":
|
||||
m.statusPicker = true
|
||||
m.statusCursor = 0
|
||||
currentNorm := data.NormalizeStatus(m.app.Status)
|
||||
for idx, opt := range statusOptions {
|
||||
if data.NormalizeStatus(opt) == currentNorm {
|
||||
m.statusCursor = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
m.clampScrollOffset()
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if m.scrollOffset < maxScroll {
|
||||
m.scrollOffset++
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
if m.scrollOffset > 0 {
|
||||
m.scrollOffset--
|
||||
}
|
||||
|
||||
case "pgdown", "ctrl+d":
|
||||
jump := m.bodyHeight() / 2
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
m.scrollOffset += jump
|
||||
if m.scrollOffset > maxScroll {
|
||||
m.scrollOffset = maxScroll
|
||||
}
|
||||
|
||||
case "pgup", "ctrl+u":
|
||||
jump := m.bodyHeight() / 2
|
||||
m.scrollOffset -= jump
|
||||
if m.scrollOffset < 0 {
|
||||
m.scrollOffset = 0
|
||||
}
|
||||
|
||||
case "home", "g":
|
||||
m.scrollOffset = 0
|
||||
|
||||
case "end", "G":
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
m.scrollOffset = maxScroll
|
||||
|
||||
case "L":
|
||||
if m.coverLetterPath != "" {
|
||||
fullPath := filepath.Join(m.careerOpsPath, filepath.FromSlash(m.coverLetterPath))
|
||||
return m, func() tea.Msg { return ViewerOpenCoverLetterMsg{Path: fullPath} }
|
||||
}
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.rebuildRender()
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m ViewerModel) bodyHeight() int {
|
||||
h := m.height - 4 // header + footer + padding
|
||||
if m.statusPicker {
|
||||
h -= (len(statusOptions) + 1)
|
||||
}
|
||||
if h < 3 {
|
||||
h = 3
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m ViewerModel) View() string {
|
||||
header := m.renderHeader()
|
||||
body := m.renderBody()
|
||||
if m.statusPicker {
|
||||
body = m.overlayStatusPicker(body)
|
||||
}
|
||||
footer := m.renderFooter()
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderHeader() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(m.theme.Text).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 2)
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Render(m.title)
|
||||
|
||||
right := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
scroll := right.Render(func() string {
|
||||
if len(m.renderedLines) == 0 {
|
||||
return ""
|
||||
}
|
||||
pct := 0
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll > 0 {
|
||||
pct = m.scrollOffset * 100 / maxScroll
|
||||
}
|
||||
if m.scrollOffset == 0 {
|
||||
return "Top"
|
||||
}
|
||||
if m.scrollOffset >= maxScroll {
|
||||
return "End"
|
||||
}
|
||||
return func() string {
|
||||
s := pct
|
||||
return string(rune('0'+s/10%10)) + string(rune('0'+s%10)) + "%"
|
||||
}()
|
||||
}())
|
||||
|
||||
gap := m.width - lipgloss.Width(m.title) - lipgloss.Width(scroll) - 4
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return style.Render(title + strings.Repeat(" ", gap) + scroll)
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderBody() string {
|
||||
bh := m.bodyHeight()
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
|
||||
if len(m.renderedLines) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
return padStyle.Render(emptyStyle.Render("(empty file)"))
|
||||
}
|
||||
|
||||
end := m.scrollOffset + bh
|
||||
if end > len(m.renderedLines) {
|
||||
end = len(m.renderedLines)
|
||||
}
|
||||
visible := m.renderedLines[m.scrollOffset:end]
|
||||
|
||||
flat := make([]string, bh)
|
||||
copy(flat, visible)
|
||||
|
||||
return padStyle.Render(strings.Join(flat, "\n"))
|
||||
}
|
||||
|
||||
// renderAll converts every raw markdown line into visual terminal lines.
|
||||
func (m ViewerModel) renderAll() []string {
|
||||
var styled []string
|
||||
i := 0
|
||||
for i < len(m.lines) {
|
||||
line := m.lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if trimmed == "" {
|
||||
styled = append(styled, "")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if isTableLine(line) {
|
||||
tableStart := i
|
||||
for i < len(m.lines) && isTableLine(m.lines[i]) {
|
||||
i++
|
||||
}
|
||||
styled = append(styled, m.renderTableBlock(m.lines[tableStart:i])...)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
i++
|
||||
var codeLines []string
|
||||
for i < len(m.lines) {
|
||||
if strings.TrimSpace(m.lines[i]) == "```" {
|
||||
i++
|
||||
break
|
||||
}
|
||||
codeLines = append(codeLines, m.lines[i])
|
||||
i++
|
||||
}
|
||||
codeStyle := lipgloss.NewStyle().Background(m.theme.Surface).Foreground(m.theme.Text)
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
for _, cl := range codeLines {
|
||||
for _, wl := range strings.Split(ansi.Wrap(" "+cl, w, ""), "\n") {
|
||||
styled = append(styled, codeStyle.Render(wl))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isSpecialBlockLine(trimmed) {
|
||||
styled = append(styled, m.styleLine(line))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
start := i
|
||||
for i < len(m.lines) {
|
||||
next := strings.TrimSpace(m.lines[i])
|
||||
if next == "" || isSpecialBlockLine(next) {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i > start {
|
||||
paraLines := m.lines[start:i]
|
||||
para := strings.Join(paraLines, " ")
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
wrapped := m.wrapParagraph(m.renderInlineElements(para), w)
|
||||
for _, wl := range wrapped {
|
||||
styled = append(styled, wl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var flat []string
|
||||
for _, s := range styled {
|
||||
if strings.IndexByte(s, '\n') >= 0 {
|
||||
flat = append(flat, strings.Split(s, "\n")...)
|
||||
} else {
|
||||
flat = append(flat, s)
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
func isTableLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return len(trimmed) > 1 && trimmed[0] == '|'
|
||||
}
|
||||
|
||||
// isTableSeparator checks if a line is a table separator (|---|---|).
|
||||
func isTableSeparator(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "|") {
|
||||
return false
|
||||
}
|
||||
cleaned := strings.NewReplacer("|", "", "-", "", ":", "", " ", "").Replace(trimmed)
|
||||
return cleaned == ""
|
||||
}
|
||||
|
||||
// parseTableCells splits a table line into trimmed cells.
|
||||
func parseTableCells(line string) []string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// Remove leading and trailing pipes
|
||||
if len(trimmed) > 0 && trimmed[0] == '|' {
|
||||
trimmed = trimmed[1:]
|
||||
}
|
||||
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '|' {
|
||||
trimmed = trimmed[:len(trimmed)-1]
|
||||
}
|
||||
parts := strings.Split(trimmed, "|")
|
||||
cells := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
cells[i] = strings.TrimSpace(p)
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func detectAlignment(sep string) lipgloss.Position {
|
||||
s := strings.TrimSpace(sep)
|
||||
if strings.HasPrefix(s, ":") && strings.HasSuffix(s, ":") {
|
||||
return lipgloss.Center
|
||||
}
|
||||
if strings.HasSuffix(s, ":") {
|
||||
return lipgloss.Right
|
||||
}
|
||||
return lipgloss.Left
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderTableBlock(lines []string) []string {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var headers []string
|
||||
var dataRows [][]string
|
||||
var alignments []lipgloss.Position
|
||||
|
||||
for _, line := range lines {
|
||||
if isTableSeparator(line) {
|
||||
if len(alignments) == 0 {
|
||||
for _, cell := range parseTableCells(line) {
|
||||
alignments = append(alignments, detectAlignment(cell))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
cells := parseTableCells(line)
|
||||
rendered := make([]string, len(cells))
|
||||
for i, c := range cells {
|
||||
rendered[i] = m.renderInlineElements(c)
|
||||
}
|
||||
if headers == nil {
|
||||
headers = rendered
|
||||
} else {
|
||||
dataRows = append(dataRows, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
if len(headers) == 0 {
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
result = append(result, m.styleLine(line))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
|
||||
borderStyle := lipgloss.NewStyle().Foreground(m.theme.Overlay)
|
||||
t := table.New().
|
||||
Width(w).
|
||||
Wrap(true).
|
||||
BorderStyle(borderStyle).
|
||||
BorderTop(true).BorderBottom(true).
|
||||
BorderLeft(true).BorderRight(true).
|
||||
BorderHeader(true).BorderColumn(true)
|
||||
|
||||
t.Headers(headers...)
|
||||
if len(dataRows) > 0 {
|
||||
t.Rows(dataRows...)
|
||||
}
|
||||
|
||||
t.StyleFunc(func(row, col int) lipgloss.Style {
|
||||
st := lipgloss.NewStyle().Padding(0, 1)
|
||||
if row == table.HeaderRow {
|
||||
return st.Bold(true).Foreground(m.theme.Sky)
|
||||
}
|
||||
if col < len(alignments) {
|
||||
st = st.Align(alignments[col])
|
||||
}
|
||||
return st.Foreground(m.theme.Text)
|
||||
})
|
||||
|
||||
return strings.Split(t.String(), "\n")
|
||||
}
|
||||
|
||||
var (
|
||||
reBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
reBareURL = regexp.MustCompile(`https?://\S*[^\s\)\]\.,;:!?]`)
|
||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
reListNumber = regexp.MustCompile(`^(\s*\d+\.\s+)(.*)$`)
|
||||
reCoverLetterPDF = regexp.MustCompile(`PDF generated:\s*(output/[^\s]+\.pdf)`)
|
||||
reRelPDFPath = regexp.MustCompile(`output/cv-[^\s\)\]\.,;:!?"']+\.pdf`)
|
||||
)
|
||||
|
||||
func isHeadingLine(line string) bool {
|
||||
return strings.HasPrefix(line, "# ") ||
|
||||
strings.HasPrefix(line, "## ") ||
|
||||
strings.HasPrefix(line, "### ") ||
|
||||
strings.HasPrefix(line, "#### ") ||
|
||||
strings.HasPrefix(line, "##### ") ||
|
||||
strings.HasPrefix(line, "###### ")
|
||||
}
|
||||
|
||||
func isSpecialBlockLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return isHeadingLine(trimmed) ||
|
||||
trimmed == "---" || trimmed == "***" ||
|
||||
strings.HasPrefix(trimmed, "> ") ||
|
||||
strings.HasPrefix(trimmed, "|") ||
|
||||
strings.HasPrefix(trimmed, "```") ||
|
||||
strings.HasPrefix(trimmed, "- ") ||
|
||||
strings.HasPrefix(trimmed, "* ") ||
|
||||
reListNumber.MatchString(trimmed) ||
|
||||
(strings.HasPrefix(trimmed, "**") && strings.Contains(trimmed, ":**"))
|
||||
}
|
||||
|
||||
func (m ViewerModel) wrapParagraph(text string, width int) []string {
|
||||
if width <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
wrapped := ansi.Wrap(text, width, "")
|
||||
return strings.Split(wrapped, "\n")
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderInlineElements(line string) string {
|
||||
return m.renderInlineElementsAs(line, m.theme.Subtext)
|
||||
}
|
||||
|
||||
// renderInlineElementsAs walks the raw line once and reapplies baseColor around
|
||||
// every plain-text span, so resets emitted by inline tokens (code, bold, link,
|
||||
// bare URL) don't leak through to subsequent text.
|
||||
func (m ViewerModel) renderInlineElementsAs(line string, baseColor lipgloss.Color) string {
|
||||
baseStyle := lipgloss.NewStyle().Foreground(baseColor)
|
||||
codeStyle := lipgloss.NewStyle().Background(m.theme.Surface).Foreground(m.theme.Text)
|
||||
boldStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Yellow)
|
||||
linkStyle := lipgloss.NewStyle().Foreground(m.theme.Blue)
|
||||
|
||||
var b strings.Builder
|
||||
rest := line
|
||||
for rest != "" {
|
||||
match := findInlineMatch(rest, codeStyle, boldStyle, linkStyle, m.careerOpsPath)
|
||||
if match == nil {
|
||||
b.WriteString(baseStyle.Render(rest))
|
||||
break
|
||||
}
|
||||
if match.start > 0 {
|
||||
b.WriteString(baseStyle.Render(rest[:match.start]))
|
||||
}
|
||||
b.WriteString(match.rendered)
|
||||
rest = rest[match.end:]
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type inlineMatch struct {
|
||||
start, end int
|
||||
rendered string
|
||||
}
|
||||
|
||||
func findInlineMatch(s string, codeStyle, boldStyle, linkStyle lipgloss.Style, careerOpsPath string) *inlineMatch {
|
||||
var best *inlineMatch
|
||||
consider := func(loc []int, rendered func() string) {
|
||||
if loc == nil || (best != nil && loc[0] >= best.start) {
|
||||
return
|
||||
}
|
||||
best = &inlineMatch{start: loc[0], end: loc[1], rendered: rendered()}
|
||||
}
|
||||
|
||||
if loc := reInlineCode.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string { return codeStyle.Render(s[loc[0]+1 : loc[1]-1]) })
|
||||
}
|
||||
if loc := reBold.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string { return boldStyle.Render(s[loc[0]+2 : loc[1]-2]) })
|
||||
}
|
||||
if loc := reLink.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string {
|
||||
sm := reLink.FindStringSubmatch(s[loc[0]:loc[1]])
|
||||
if len(sm) >= 2 {
|
||||
return linkStyle.Render(sm[1])
|
||||
}
|
||||
return s[loc[0]:loc[1]]
|
||||
})
|
||||
}
|
||||
if loc := reBareURL.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string { return linkStyle.Render(s[loc[0]:loc[1]]) })
|
||||
}
|
||||
if loc := reRelPDFPath.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string {
|
||||
relPath := s[loc[0]:loc[1]]
|
||||
styled := linkStyle.Render(relPath)
|
||||
if careerOpsPath == "" {
|
||||
return styled
|
||||
}
|
||||
joined := filepath.Join(careerOpsPath, filepath.FromSlash(relPath))
|
||||
absPath, err := filepath.Abs(joined)
|
||||
if err != nil {
|
||||
return styled
|
||||
}
|
||||
forward := filepath.ToSlash(absPath)
|
||||
if !strings.HasPrefix(forward, "/") {
|
||||
forward = "/" + forward // Windows: C:/... → /C:/...
|
||||
}
|
||||
// OSC 8 hyperlink: ESC ] 8 ; ; URL BEL text ESC ] 8 ; ; BEL
|
||||
return "\x1b]8;;" + "file://" + forward + "\x07" + styled + "\x1b]8;;\x07"
|
||||
})
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (m ViewerModel) styleLine(line string) string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "# ") && !strings.HasPrefix(trimmed, "## ") {
|
||||
content := strings.TrimPrefix(trimmed, "# ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") {
|
||||
content := strings.TrimPrefix(trimmed, "## ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Mauve).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "### ") && !strings.HasPrefix(trimmed, "#### ") {
|
||||
content := strings.TrimPrefix(trimmed, "### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#### ") && !strings.HasPrefix(trimmed, "##### ") {
|
||||
content := strings.TrimPrefix(trimmed, "#### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Subtext).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "##### ") && !strings.HasPrefix(trimmed, "###### ") {
|
||||
content := strings.TrimPrefix(trimmed, "##### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Overlay).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "###### ") {
|
||||
content := strings.TrimPrefix(trimmed, "###### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Overlay).Width(w).Render(" " + content)
|
||||
}
|
||||
if trimmed == "---" || trimmed == "***" {
|
||||
return lipgloss.NewStyle().Foreground(m.theme.Overlay).Width(w).Render(strings.Repeat("─", w))
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "> ") {
|
||||
content := strings.TrimPrefix(trimmed, "> ")
|
||||
border := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render("▎ ")
|
||||
textStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Italic(true)
|
||||
wrapped := strings.Split(ansi.Wrap(textStyle.Render(content), w-2, ""), "\n")
|
||||
result := make([]string, 0, len(wrapped))
|
||||
for i, line := range wrapped {
|
||||
if i == 0 {
|
||||
result = append(result, border+line)
|
||||
} else {
|
||||
result = append(result, strings.Repeat(" ", ansi.StringWidth(border))+line)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "**") && strings.Contains(trimmed, ":**") {
|
||||
styled := m.renderInlineElements(line)
|
||||
return ansi.Wrap(styled, w, "")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
||||
content := trimmed[2:]
|
||||
marker := lipgloss.NewStyle().Foreground(m.theme.Blue).Render("• ")
|
||||
return m.renderListItem(marker, content, w)
|
||||
}
|
||||
if reListNumber.MatchString(trimmed) {
|
||||
sm := reListNumber.FindStringSubmatch(trimmed)
|
||||
if len(sm) >= 3 {
|
||||
marker := lipgloss.NewStyle().Foreground(m.theme.Blue).Render(sm[1])
|
||||
return m.renderListItem(marker, sm[2], w)
|
||||
}
|
||||
}
|
||||
|
||||
styled := m.renderInlineElementsAs(trimmed, m.theme.Subtext)
|
||||
return ansi.Wrap(styled, w, "")
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderListItem(marker, content string, width int) string {
|
||||
markerWidth := ansi.StringWidth(marker)
|
||||
textWidth := width - markerWidth
|
||||
if textWidth < 10 {
|
||||
textWidth = 10
|
||||
}
|
||||
styled := m.renderInlineElementsAs(content, m.theme.Text)
|
||||
lines := strings.Split(ansi.Wrap(styled, textWidth, ""), "\n")
|
||||
result := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
if i == 0 {
|
||||
result = append(result, marker+line)
|
||||
} else {
|
||||
result = append(result, strings.Repeat(" ", markerWidth)+line)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderFooter() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(m.theme.Subtext).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 1)
|
||||
|
||||
keyStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Text)
|
||||
descStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
if m.statusPicker {
|
||||
return style.Render(
|
||||
keyStyle.Render("↑/↓/j/k") + descStyle.Render(" select ") +
|
||||
keyStyle.Render("Enter") + descStyle.Render(" confirm ") +
|
||||
keyStyle.Render("Esc/q") + descStyle.Render(" cancel"))
|
||||
}
|
||||
|
||||
footer := keyStyle.Render("↑↓") + descStyle.Render(" scroll ") +
|
||||
keyStyle.Render("PgUp/Dn") + descStyle.Render(" page ") +
|
||||
keyStyle.Render("g/G") + descStyle.Render(" top/end ") +
|
||||
keyStyle.Render("c") + descStyle.Render(" status ") +
|
||||
keyStyle.Render("Esc") + descStyle.Render(" back")
|
||||
|
||||
if m.coverLetterPath != "" {
|
||||
footer += " " + keyStyle.Render("L") + descStyle.Render(" cover letter")
|
||||
}
|
||||
|
||||
return style.Render(footer)
|
||||
}
|
||||
|
||||
func (m ViewerModel) handleStatusPicker(msg tea.KeyMsg) (ViewerModel, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.statusPicker = false
|
||||
m.clampScrollOffset()
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
m.statusCursor++
|
||||
if m.statusCursor >= len(statusOptions) {
|
||||
m.statusCursor = len(statusOptions) - 1
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
m.statusCursor--
|
||||
if m.statusCursor < 0 {
|
||||
m.statusCursor = 0
|
||||
}
|
||||
|
||||
case "enter":
|
||||
m.statusPicker = false
|
||||
m.clampScrollOffset()
|
||||
newStatus := statusOptions[m.statusCursor]
|
||||
return m, func() tea.Msg {
|
||||
return ViewerUpdateStatusMsg{
|
||||
App: m.app,
|
||||
NewStatus: newStatus,
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m ViewerModel) overlayStatusPicker(body string) string {
|
||||
bodyLines := strings.Split(body, "\n")
|
||||
|
||||
pickerWidth := 30
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
Foreground(m.theme.Blue).
|
||||
Bold(true)
|
||||
|
||||
var picker []string
|
||||
picker = append(picker, padStyle.Render(borderStyle.Render("Change status:")))
|
||||
|
||||
for i, opt := range statusOptions {
|
||||
style := lipgloss.NewStyle().Foreground(m.theme.Text).Width(pickerWidth)
|
||||
if i == m.statusCursor {
|
||||
style = style.Background(m.theme.Overlay).Bold(true)
|
||||
}
|
||||
prefix := " "
|
||||
if i == m.statusCursor {
|
||||
prefix = "> "
|
||||
}
|
||||
picker = append(picker, padStyle.Render(style.Render(prefix+opt)))
|
||||
}
|
||||
|
||||
bodyLines = append(bodyLines, picker...)
|
||||
return strings.Join(bodyLines, "\n")
|
||||
}
|
||||
|
||||
// UpdateAppStatus updates the status of the current application inside the viewer model.
|
||||
func (m *ViewerModel) UpdateAppStatus(newStatus string) {
|
||||
m.app.Status = newStatus
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func TestViewerRebuildRenderClampsScrollOffset(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: []string{"short"},
|
||||
scrollOffset: 20,
|
||||
width: 80,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
m.rebuildRender()
|
||||
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if m.scrollOffset > maxScroll {
|
||||
t.Fatalf("expected scrollOffset <= %d after rebuild, got %d", maxScroll, m.scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInlineElementsLeavesTrailingPunctuationUnstyled(t *testing.T) {
|
||||
match := reBareURL.FindString("Visit https://example.com.")
|
||||
|
||||
if match != "https://example.com" {
|
||||
t.Fatalf("expected URL match without trailing period, got %q", match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerWrapsFencedCodeLines(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: []string{
|
||||
"```",
|
||||
strings.Repeat("x", 40),
|
||||
"```",
|
||||
},
|
||||
width: 20,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := m.renderAll()
|
||||
maxWidth := m.width - 6
|
||||
if maxWidth < 10 {
|
||||
maxWidth = 10
|
||||
}
|
||||
|
||||
if len(rendered) < 2 {
|
||||
t.Fatalf("expected fenced code to wrap into multiple lines, got %d", len(rendered))
|
||||
}
|
||||
for _, line := range rendered {
|
||||
if width := ansi.StringWidth(line); width > maxWidth {
|
||||
t.Fatalf("expected fenced code line width <= %d, got %d for %q", maxWidth, width, ansi.Strip(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerRendersInlineMarkdownBeforeParagraphWrapping(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: []string{
|
||||
"See [documentation](https://example.com/really-long-path) before continuing.",
|
||||
},
|
||||
width: 30,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := strings.Join(m.renderAll(), "\n")
|
||||
plain := ansi.Strip(rendered)
|
||||
|
||||
if strings.Contains(plain, "[") || strings.Contains(plain, "](") {
|
||||
t.Fatalf("expected rendered paragraph to hide markdown link syntax, got %q", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "documentation") {
|
||||
t.Fatalf("expected rendered paragraph to keep link text, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerEmptyContentRendersPlaceholder(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: nil,
|
||||
width: 40,
|
||||
height: 10,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
m.rebuildRender()
|
||||
|
||||
if len(m.renderedLines) != 0 {
|
||||
t.Fatalf("expected zero rendered lines for empty content, got %d", len(m.renderedLines))
|
||||
}
|
||||
|
||||
body := ansi.Strip(m.renderBody())
|
||||
if !strings.Contains(body, "(empty file)") {
|
||||
t.Fatalf("expected empty placeholder, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerInlineRenderingHandlesMixedTokens(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
width: 60,
|
||||
height: 10,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := m.renderInlineElementsAs(
|
||||
"start `code` mid **bold** then [link](https://example.com) end https://bare.example.com.",
|
||||
m.theme.Subtext,
|
||||
)
|
||||
plain := ansi.Strip(rendered)
|
||||
|
||||
for _, want := range []string{"start ", "code", " mid ", "bold", " then ", "link", " end ", "https://bare.example.com"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("expected plain output to contain %q, got %q", want, plain)
|
||||
}
|
||||
}
|
||||
for _, syntax := range []string{"`", "**", "[", "](", "](http"} {
|
||||
if strings.Contains(plain, syntax) {
|
||||
t.Fatalf("expected markdown syntax %q to be hidden, got %q", syntax, plain)
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(plain, ".") == false {
|
||||
t.Fatalf("expected trailing punctuation outside the bare URL, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerIndentsWrappedBlockquoteLines(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
width: 24,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := m.styleLine("> " + strings.Repeat("quoted ", 8))
|
||||
lines := strings.Split(ansi.Strip(rendered), "\n")
|
||||
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected wrapped blockquote to render multiple lines, got %d", len(lines))
|
||||
}
|
||||
if !strings.HasPrefix(lines[0], "▎ ") {
|
||||
t.Fatalf("expected first blockquote line to keep border, got %q", lines[0])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], " ") {
|
||||
t.Fatalf("expected wrapped blockquote continuation to align with text, got %q", lines[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/data"
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
"github.com/santifer/career-ops/dashboard/internal/ui/screens"
|
||||
)
|
||||
|
||||
type viewState int
|
||||
|
||||
const (
|
||||
viewPipeline viewState = iota
|
||||
viewReport
|
||||
viewProgress
|
||||
)
|
||||
|
||||
type appModel struct {
|
||||
pipeline screens.PipelineModel
|
||||
viewer screens.ViewerModel
|
||||
progress screens.ProgressModel
|
||||
state viewState
|
||||
careerOpsPath string
|
||||
theme theme.Theme
|
||||
progressMetrics model.ProgressMetrics
|
||||
}
|
||||
|
||||
func (m *appModel) reloadPipelineData() {
|
||||
apps := data.ParseApplications(m.careerOpsPath)
|
||||
metrics := data.ComputeMetrics(apps)
|
||||
m.progressMetrics = data.ComputeProgressMetrics(apps)
|
||||
m.pipeline = m.pipeline.WithReloadedData(apps, metrics)
|
||||
}
|
||||
|
||||
func (m appModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.pipeline.Resize(msg.Width, msg.Height)
|
||||
if m.state == viewReport {
|
||||
m.viewer.Resize(msg.Width, msg.Height)
|
||||
}
|
||||
if m.state == viewProgress {
|
||||
m.progress.Resize(msg.Width, msg.Height)
|
||||
}
|
||||
pm, cmd := m.pipeline.Update(msg)
|
||||
m.pipeline = pm
|
||||
return m, cmd
|
||||
|
||||
case screens.PipelineClosedMsg:
|
||||
return m, tea.Quit
|
||||
|
||||
case screens.PipelineLoadReportMsg:
|
||||
archetype, tldr, remote, comp := data.LoadReportSummary(msg.CareerOpsPath, msg.ReportPath)
|
||||
m.pipeline.EnrichReport(msg.ReportPath, archetype, tldr, remote, comp)
|
||||
return m, nil
|
||||
|
||||
case screens.PipelineUpdateStatusMsg:
|
||||
err := data.UpdateApplicationStatus(msg.CareerOpsPath, msg.App, msg.NewStatus)
|
||||
if err != nil {
|
||||
// Log the error but still reload data to keep UI consistent
|
||||
fmt.Fprintf(os.Stderr, "WARN: status update failed: %v\n", err)
|
||||
}
|
||||
m.reloadPipelineData()
|
||||
return m, nil
|
||||
|
||||
case screens.PipelineRefreshMsg:
|
||||
m.reloadPipelineData()
|
||||
return m, nil
|
||||
|
||||
case screens.PipelineOpenReportMsg:
|
||||
m.viewer = screens.NewViewerModel(
|
||||
m.theme,
|
||||
m.careerOpsPath,
|
||||
msg.Path, msg.Title,
|
||||
m.pipeline.Width(), m.pipeline.Height(),
|
||||
msg.App,
|
||||
)
|
||||
m.state = viewReport
|
||||
return m, nil
|
||||
|
||||
case screens.ViewerClosedMsg:
|
||||
m.state = viewPipeline
|
||||
return m, nil
|
||||
|
||||
case screens.ViewerOpenCoverLetterMsg:
|
||||
path := msg.Path
|
||||
return m, func() tea.Msg {
|
||||
if err := openWithDefaultApp(path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: could not open cover letter: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
case screens.ViewerUpdateStatusMsg:
|
||||
err := data.UpdateApplicationStatus(m.careerOpsPath, msg.App, msg.NewStatus)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: status update failed: %v\n", err)
|
||||
}
|
||||
m.viewer.UpdateAppStatus(msg.NewStatus)
|
||||
m.reloadPipelineData()
|
||||
return m, nil
|
||||
|
||||
case screens.PipelineOpenProgressMsg:
|
||||
m.progress = screens.NewProgressModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
m.progressMetrics,
|
||||
m.pipeline.Width(), m.pipeline.Height(),
|
||||
)
|
||||
m.state = viewProgress
|
||||
return m, nil
|
||||
|
||||
case screens.ProgressClosedMsg:
|
||||
m.state = viewPipeline
|
||||
return m, nil
|
||||
|
||||
case screens.PipelineOpenURLMsg:
|
||||
return m, openCmd(msg.URL)
|
||||
|
||||
case screens.PipelineOpenPDFMsg:
|
||||
return m, openCmd(msg.Path)
|
||||
|
||||
case screens.PipelineGeneratePDFMsg:
|
||||
return m, runGeneratePDF(msg)
|
||||
|
||||
default:
|
||||
if m.state == viewReport {
|
||||
vm, cmd := m.viewer.Update(msg)
|
||||
m.viewer = vm
|
||||
return m, cmd
|
||||
}
|
||||
if m.state == viewProgress {
|
||||
pg, cmd := m.progress.Update(msg)
|
||||
m.progress = pg
|
||||
return m, cmd
|
||||
}
|
||||
pm, cmd := m.pipeline.Update(msg)
|
||||
m.pipeline = pm
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
// openCmd wraps openWithDefaultApp (OS-specific) as a tea.Cmd. Shared by the
|
||||
// job-URL (`o`) and CV-PDF (`d`) actions.
|
||||
func openCmd(target string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := openWithDefaultApp(target); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: failed to open %q: %v\n", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// runGeneratePDF shells out to node generate-pdf.mjs in the career-ops root,
|
||||
// opens the resulting PDF on success, and reports the outcome back to the
|
||||
// pipeline screen as a PipelinePDFGeneratedMsg. Runs in a tea.Cmd goroutine,
|
||||
// so the UI stays responsive while Chromium renders.
|
||||
func runGeneratePDF(msg screens.PipelineGeneratePDFMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
args := []string{"generate-pdf.mjs", msg.HTMLPath, msg.PDFPath}
|
||||
if msg.Format != "" {
|
||||
args = append(args, "--format="+msg.Format)
|
||||
}
|
||||
if msg.ReportNumber != "" {
|
||||
args = append(args, "--report="+msg.ReportNumber)
|
||||
}
|
||||
cmd := exec.Command("node", args...)
|
||||
cmd.Dir = msg.CareerOpsPath
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return screens.PipelinePDFGeneratedMsg{Err: summarizeCmdError(err, out)}
|
||||
}
|
||||
pdfAbs := filepath.Join(msg.CareerOpsPath, filepath.FromSlash(msg.PDFPath))
|
||||
if err := openWithDefaultApp(pdfAbs); err != nil {
|
||||
return screens.PipelinePDFGeneratedMsg{Err: fmt.Sprintf("PDF generated but could not open: %v", err)}
|
||||
}
|
||||
return screens.PipelinePDFGeneratedMsg{Path: pdfAbs}
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeCmdError condenses a failed command into one help-bar-sized line:
|
||||
// the last non-empty output line when there is one (generate-pdf.mjs prints
|
||||
// its error there), otherwise the exec error itself.
|
||||
func summarizeCmdError(err error, out []byte) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (m appModel) View() string {
|
||||
switch m.state {
|
||||
case viewReport:
|
||||
return m.viewer.View()
|
||||
case viewProgress:
|
||||
return m.progress.View()
|
||||
default:
|
||||
return m.pipeline.View()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
pathFlag := flag.String("path", ".", "Path to career-ops directory")
|
||||
flag.Parse()
|
||||
|
||||
careerOpsPath := *pathFlag
|
||||
|
||||
// Load applications
|
||||
apps := data.ParseApplications(careerOpsPath)
|
||||
if apps == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: could not find applications.md in %s or %s/data/\n", careerOpsPath, careerOpsPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Compute metrics
|
||||
metrics := data.ComputeMetrics(apps)
|
||||
progressMetrics := data.ComputeProgressMetrics(apps)
|
||||
|
||||
// Batch-load all report summaries
|
||||
t := theme.NewTheme("auto")
|
||||
pm := screens.NewPipelineModel(t, apps, metrics, careerOpsPath, 120, 40)
|
||||
|
||||
for _, app := range apps {
|
||||
if app.ReportPath == "" {
|
||||
continue
|
||||
}
|
||||
archetype, tldr, remote, comp := data.LoadReportSummary(careerOpsPath, app.ReportPath)
|
||||
if archetype != "" || tldr != "" || remote != "" || comp != "" {
|
||||
pm.EnrichReport(app.ReportPath, archetype, tldr, remote, comp)
|
||||
}
|
||||
}
|
||||
|
||||
m := appModel{
|
||||
pipeline: pm,
|
||||
careerOpsPath: careerOpsPath,
|
||||
theme: t,
|
||||
progressMetrics: progressMetrics,
|
||||
}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenWithDefaultAppUsesPlatformCommand(t *testing.T) {
|
||||
previous := runOpenCommand
|
||||
defer func() { runOpenCommand = previous }()
|
||||
|
||||
var gotName string
|
||||
var gotArgs []string
|
||||
runOpenCommand = func(name string, args ...string) error {
|
||||
gotName = name
|
||||
gotArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
target := "https://example.com/jobs?id=1&next=2"
|
||||
if err := openWithDefaultApp(target); err != nil {
|
||||
t.Fatalf("openWithDefaultApp returned error: %v", err)
|
||||
}
|
||||
|
||||
wantName := "xdg-open"
|
||||
if runtime.GOOS == "darwin" {
|
||||
wantName = "open"
|
||||
}
|
||||
if gotName != wantName {
|
||||
t.Fatalf("command name = %q, want %q", gotName, wantName)
|
||||
}
|
||||
if len(gotArgs) != 1 || gotArgs[0] != target {
|
||||
t.Fatalf("command args = %#v, want single target arg %q", gotArgs, target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
func openWithDefaultApp(target string) error {
|
||||
return runOpenCommand("open", target)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
func openWithDefaultApp(target string) error {
|
||||
return runOpenCommand("xdg-open", target)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
var runOpenCommand = func(name string, args ...string) error {
|
||||
return exec.Command(name, args...).Run()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows && !darwin && !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func openWithDefaultApp(target string) error {
|
||||
return fmt.Errorf("opening URLs is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
type shellExecuteFunc func(windows.Handle, *uint16, *uint16, *uint16, *uint16, int32) error
|
||||
|
||||
var shellExecute = shellExecuteFunc(windows.ShellExecute)
|
||||
|
||||
func openWithDefaultApp(target string) error {
|
||||
verb, err := windows.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := windows.UTF16PtrFromString(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return shellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestOpenWithDefaultAppWindowsUsesShellExecute(t *testing.T) {
|
||||
previous := shellExecute
|
||||
defer func() { shellExecute = previous }()
|
||||
|
||||
var called bool
|
||||
var gotVerb string
|
||||
var gotFile string
|
||||
var gotShowCmd int32
|
||||
shellExecute = func(_ windows.Handle, verb *uint16, file *uint16, _ *uint16, _ *uint16, showCmd int32) error {
|
||||
called = true
|
||||
gotVerb = windows.UTF16PtrToString(verb)
|
||||
gotFile = windows.UTF16PtrToString(file)
|
||||
gotShowCmd = showCmd
|
||||
return nil
|
||||
}
|
||||
|
||||
target := "https://example.com/jobs?id=1&next=calc"
|
||||
if err := openWithDefaultApp(target); err != nil {
|
||||
t.Fatalf("openWithDefaultApp returned error: %v", err)
|
||||
}
|
||||
|
||||
if !called {
|
||||
t.Fatal("ShellExecuteW was not called")
|
||||
}
|
||||
if gotVerb != "open" {
|
||||
t.Fatalf("verb = %q, want open", gotVerb)
|
||||
}
|
||||
if gotFile != target {
|
||||
t.Fatalf("file = %q, want exact target %q", gotFile, target)
|
||||
}
|
||||
if gotShowCmd != windows.SW_SHOWNORMAL {
|
||||
t.Fatalf("showCmd = %d, want %d", gotShowCmd, windows.SW_SHOWNORMAL)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user