package screens import ( "fmt" "math" "os" "path/filepath" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/santifer/career-ops/dashboard/internal/data" "github.com/santifer/career-ops/dashboard/internal/model" "github.com/santifer/career-ops/dashboard/internal/theme" ) // PipelineClosedMsg is emitted when the pipeline screen is dismissed. type PipelineClosedMsg struct{} // PipelineOpenReportMsg is emitted when a report should be opened in FileViewer. type PipelineOpenReportMsg struct { Path string Title string JobURL string App model.CareerApplication } // PipelineOpenURLMsg is emitted when a job URL should be opened in browser. type PipelineOpenURLMsg struct { URL string } // PipelineOpenPDFMsg is emitted when a generated CV PDF should be opened // with the OS default handler. Path is absolute. type PipelineOpenPDFMsg struct { Path string } // PipelineGeneratePDFMsg requests a PDF regeneration via generate-pdf.mjs // from the application's recorded source HTML. Paths are relative to // CareerOpsPath (as recorded in the manifest). type PipelineGeneratePDFMsg struct { CareerOpsPath string ReportNumber string HTMLPath string PDFPath string Format string } // PipelinePDFGeneratedMsg reports the outcome of a regeneration. On success // Err is empty and Path holds the absolute path of the (already opened) PDF. type PipelinePDFGeneratedMsg struct { Err string Path string } // PipelineLoadReportMsg requests lazy loading of a report summary. type PipelineLoadReportMsg struct { CareerOpsPath string ReportPath string } // PipelineUpdateStatusMsg requests a status update for an application. type PipelineUpdateStatusMsg struct { CareerOpsPath string App model.CareerApplication NewStatus string } // PipelineRefreshMsg requests a full tracker reload from disk. type PipelineRefreshMsg struct{} // PipelineOpenProgressMsg is emitted when the progress screen should open. type PipelineOpenProgressMsg struct{} type reportSummary struct { archetype string tldr string remote string comp string } // Sort modes const ( sortScore = "score" sortDate = "date" sortCompany = "company" sortStatus = "status" sortLocation = "location" sortPay = "pay" sortLast = "last" ) // Filter modes const ( filterAll = "all" filterEvaluated = "evaluated" filterApplied = "applied" filterInterview = "interview" filterSkip = "skip" filterRejected = "rejected" filterDiscarded = "discarded" filterTop = "top" ) type pipelineTab struct { filter string label string } var pipelineTabs = []pipelineTab{ {filterAll, "ALL"}, {filterEvaluated, "EVALUATED"}, {filterApplied, "APPLIED"}, {filterInterview, "INTERVIEW"}, {filterTop, "TOP ≥4"}, {filterSkip, "SKIP"}, {filterRejected, "REJECTED"}, {filterDiscarded, "DISCARDED"}, } var sortCycle = []string{sortScore, sortDate, sortCompany, sortStatus, sortLocation, sortPay, sortLast} // ColumnID identifies an optional table column in the pipeline view. type ColumnID int const ( // Optional columns — user-toggleable via the column picker (C key). ColDate ColumnID = iota // APPLIED date ColLocation // LOCATION city+state ColPay // PAY range ColHasReport // RPT: ✓/— ColHasPDF // PDF: ✓/— ColLastContact // LAST contact date ) // colDef describes one optional column for the picker UI. type colDef struct { id ColumnID header string hint string width int onByDefault bool } var optionalCols = []colDef{ {ColDate, "APPLIED", "", 10, true}, {ColLocation, "LOCATION", "", 20, true}, {ColPay, "PAY", "", 16, true}, {ColHasReport, "RPT", "✓/—", 4, false}, {ColHasPDF, "PDF", "✓/—", 4, false}, {ColLastContact, "LAST", "", 10, false}, } var statusOptions = []string{"Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded", "SKIP"} // statusGroupOrder defines display order for grouped view. var statusGroupOrder = []string{"interview", "offer", "responded", "applied", "evaluated", "skip", "rejected", "discarded"} // PipelineModel implements the career pipeline dashboard screen. type PipelineModel struct { apps []model.CareerApplication filtered []model.CareerApplication metrics model.PipelineMetrics cursor int scrollOffset int sortMode string activeTab int viewMode string // "grouped" or "flat" width, height int theme theme.Theme careerOpsPath string reportCache map[string]reportSummary // Status picker sub-state statusPicker bool statusCursor int // PDF picker sub-state — shown when one application matches several // generated CVs (role variants from the same company). pdfPicker bool pdfCursor int pdfChoices []string // root-relative paths, newest first // flash is a one-shot notice rendered in place of the help bar; any // keypress clears it. flash string // Search sub-state — narrows the active tab by substring on company/role/notes. searchInput bool // true while the user is typing the query searchQuery string // committed (or in-progress) lowercased query // Column picker sub-state — opened with C, closed with esc. colPicker bool colPickerIdx int visibleCols map[ColumnID]bool } // NewPipelineModel creates a new pipeline screen. func NewPipelineModel(t theme.Theme, apps []model.CareerApplication, metrics model.PipelineMetrics, careerOpsPath string, width, height int) PipelineModel { visible := make(map[ColumnID]bool) for _, col := range optionalCols { visible[col.id] = col.onByDefault } m := PipelineModel{ apps: apps, metrics: metrics, sortMode: sortScore, activeTab: 0, viewMode: "grouped", width: width, height: height, theme: t, careerOpsPath: careerOpsPath, reportCache: make(map[string]reportSummary), visibleCols: visible, } m.applyFilterAndSort() return m } // Init implements tea.Model. func (m PipelineModel) Init() tea.Cmd { return nil } // Resize updates dimensions. func (m *PipelineModel) Resize(width, height int) { m.width = width m.height = height } // Width returns the current width. func (m PipelineModel) Width() int { return m.width } // Height returns the current height. func (m PipelineModel) Height() int { return m.height } // CopyReportCache copies the report cache from another pipeline model. func (m *PipelineModel) CopyReportCache(other *PipelineModel) { for k, v := range other.reportCache { m.reportCache[k] = v } } // EnrichReport caches report summary data for preview. func (m *PipelineModel) EnrichReport(reportPath, archetype, tldr, remote, comp string) { m.reportCache[reportPath] = reportSummary{ archetype: archetype, tldr: tldr, remote: remote, comp: comp, } } // WithReloadedData rebuilds the pipeline with fresh tracker data while preserving // the current UI state so manual refresh feels seamless. func (m PipelineModel) WithReloadedData(apps []model.CareerApplication, metrics model.PipelineMetrics) PipelineModel { selectedReportPath := "" selectedCompany := "" selectedRole := "" if app, ok := m.CurrentApp(); ok { selectedReportPath = app.ReportPath selectedCompany = app.Company selectedRole = app.Role } reloaded := NewPipelineModel(m.theme, apps, metrics, m.careerOpsPath, m.width, m.height) reloaded.sortMode = m.sortMode reloaded.activeTab = m.activeTab reloaded.viewMode = m.viewMode // Preserve search state across refresh — otherwise pressing `r` silently drops a // committed query and the user loses their place mid-investigation. reloaded.searchQuery = m.searchQuery reloaded.searchInput = m.searchInput // Preserve user's column visibility choices across refresh. reloaded.visibleCols = m.visibleCols reloaded.applyFilterAndSort() reloaded.CopyReportCache(&m) for i, app := range reloaded.filtered { if selectedReportPath != "" && app.ReportPath == selectedReportPath { reloaded.cursor = i reloaded.adjustScroll() return reloaded } if selectedReportPath == "" && app.Company == selectedCompany && app.Role == selectedRole { reloaded.cursor = i reloaded.adjustScroll() return reloaded } } if len(reloaded.filtered) == 0 { reloaded.cursor = 0 reloaded.scrollOffset = 0 return reloaded } if m.cursor >= len(reloaded.filtered) { reloaded.cursor = len(reloaded.filtered) - 1 } else if m.cursor > 0 { reloaded.cursor = m.cursor } reloaded.adjustScroll() return reloaded } // CurrentApp returns the currently selected application, if any. func (m PipelineModel) CurrentApp() (model.CareerApplication, bool) { if m.cursor < 0 || m.cursor >= len(m.filtered) { return model.CareerApplication{}, false } return m.filtered[m.cursor], true } // Update handles input for the pipeline screen. func (m PipelineModel) Update(msg tea.Msg) (PipelineModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: m.flash = "" if m.colPicker { return m.handleColPicker(msg) } if m.statusPicker { return m.handleStatusPicker(msg) } if m.pdfPicker { return m.handlePDFPicker(msg) } if m.searchInput { return m.handleSearchInput(msg) } return m.handleKey(msg) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case PipelinePDFGeneratedMsg: if msg.Err != "" { m.flash = "PDF regeneration failed: " + msg.Err } else { m.flash = "PDF regenerated and opened: " + filepath.Base(msg.Path) } return m, nil } return m, nil } func (m PipelineModel) handleKey(msg tea.KeyMsg) (PipelineModel, tea.Cmd) { switch msg.String() { case "esc": // While a search is committed, Esc clears the search (matches vim's `:nohl` // ergonomics). With no query, Esc is a no-op — q is the only quit key, which // keeps the help bar honest and avoids accidental exits. if m.searchQuery != "" { m.searchQuery = "" m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 return m, m.loadCurrentReport() } return m, nil case "q": return m, func() tea.Msg { return PipelineClosedMsg{} } case "/": // Open search input. Pre-fill with the current query so refining is one keystroke away. m.searchInput = true return m, nil case "down", "j": if len(m.filtered) > 0 { m.cursor++ if m.cursor >= len(m.filtered) { m.cursor = len(m.filtered) - 1 } m.adjustScroll() return m, m.loadCurrentReport() } case "up", "k": if len(m.filtered) > 0 { m.cursor-- if m.cursor < 0 { m.cursor = 0 } m.adjustScroll() return m, m.loadCurrentReport() } case "s": // Cycle sort mode for i, s := range sortCycle { if s == m.sortMode { m.sortMode = sortCycle[(i+1)%len(sortCycle)] break } } m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 case "f", "right", "l": m.activeTab++ if m.activeTab >= len(pipelineTabs) { m.activeTab = 0 } m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 case "left", "h": m.activeTab-- if m.activeTab < 0 { m.activeTab = len(pipelineTabs) - 1 } m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 case "v": if m.viewMode == "grouped" { m.viewMode = "flat" } else { m.viewMode = "grouped" } case "enter": if app, ok := m.CurrentApp(); ok && app.ReportPath != "" { fullPath := filepath.Join(m.careerOpsPath, app.ReportPath) title := fmt.Sprintf("%s — %s", app.Company, app.Role) jobURL := app.JobURL return m, func() tea.Msg { return PipelineOpenReportMsg{Path: fullPath, Title: title, JobURL: jobURL, App: app} } } case "o": if app, ok := m.CurrentApp(); ok && app.JobURL != "" { return m, func() tea.Msg { return PipelineOpenURLMsg{URL: app.JobURL} } } case "d": if app, ok := m.CurrentApp(); ok { manifest := data.LoadPDFManifest(m.careerOpsPath) candidates := data.ResolvePDFs(m.careerOpsPath, app, manifest) if len(candidates) == 0 { m.flash = "No CV PDF found for this application — generate one with /career-ops pdf" } else { return m, m.openPDFCmd(candidates[0]) // newest first } } case "D": if app, ok := m.CurrentApp(); ok { manifest := data.LoadPDFManifest(m.careerOpsPath) entry, found := manifest.Lookup(app) // Manifest lookup requires a report number; fall back to PDF-path // index when the manifest was written without --report (common case). if !found || entry.HTMLPath == "" { byPath := data.LoadPDFEntriesByPath(m.careerOpsPath) candidates := data.ResolvePDFs(m.careerOpsPath, app, manifest) for _, c := range candidates { if e, ok := byPath[c]; ok && e.HTMLPath != "" { entry = e found = true break } } } if !found || entry.HTMLPath == "" { m.flash = "No source HTML found for this application — run /career-ops pdf first" return m, nil } if _, err := os.Stat(filepath.Join(m.careerOpsPath, filepath.FromSlash(entry.HTMLPath))); err != nil { m.flash = "Source HTML missing: " + entry.HTMLPath return m, nil } m.flash = "Regenerating PDF via generate-pdf.mjs — this takes a few seconds..." path, report := m.careerOpsPath, entry.ReportNumber html, pdf, format := entry.HTMLPath, entry.PDFPath, entry.Format return m, func() tea.Msg { return PipelineGeneratePDFMsg{ CareerOpsPath: path, ReportNumber: report, HTMLPath: html, PDFPath: pdf, Format: format, } } } case "p": return m, func() tea.Msg { return PipelineOpenProgressMsg{} } case "r": return m, func() tea.Msg { return PipelineRefreshMsg{} } case "C": m.colPicker = true m.colPickerIdx = 0 return m, nil case "c": if len(m.filtered) > 0 { m.statusPicker = true m.statusCursor = 0 } case "g": if len(m.filtered) > 0 { m.cursor = 0 m.scrollOffset = 0 return m, m.loadCurrentReport() } case "G": if len(m.filtered) > 0 { m.cursor = len(m.filtered) - 1 m.adjustScroll() return m, m.loadCurrentReport() } case "pgdown", "ctrl+d": if len(m.filtered) > 0 { halfPage := m.height / 2 if halfPage < 1 { halfPage = 1 } m.cursor += halfPage if m.cursor >= len(m.filtered) { m.cursor = len(m.filtered) - 1 } m.adjustScroll() return m, m.loadCurrentReport() } case "pgup", "ctrl+u": if len(m.filtered) > 0 { halfPage := m.height / 2 if halfPage < 1 { halfPage = 1 } m.cursor -= halfPage if m.cursor < 0 { m.cursor = 0 } m.adjustScroll() return m, m.loadCurrentReport() } } return m, nil } // handleSearchInput consumes keys while the search input bar is open. // Esc cancels (closes input AND clears query). Enter commits (closes input, // keeps query, refreshes filtered list). Backspace + printable chars edit // the query and live-update the filter so the user sees results as they type. // // Report previews are NOT lazy-loaded on every keystroke — that would trigger // a synchronous os.ReadFile per rune/backspace/ctrl+u and stutter live // typing. Instead the load fires once when the user commits (Enter) or // cancels (Esc); subsequent cursor movement in handleKey loads as before. func (m PipelineModel) handleSearchInput(msg tea.KeyMsg) (PipelineModel, tea.Cmd) { switch msg.String() { case "esc": m.searchInput = false m.searchQuery = "" m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 return m, m.loadCurrentReport() case "enter": m.searchInput = false // Query already applied during typing; load the preview for the // committed first match (skipped during typing for perf). return m, m.loadCurrentReport() case "backspace": if len(m.searchQuery) > 0 { // Drop the last UTF-8 rune so multi-byte characters delete cleanly. runes := []rune(m.searchQuery) m.searchQuery = string(runes[:len(runes)-1]) m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 } return m, nil case "ctrl+u": // vim-flavored: clear the in-progress query without leaving search mode. m.searchQuery = "" m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 return m, nil } // Append printable runes (ignore other special keys like arrows / ctrl-combos). if r := msg.Runes; len(r) > 0 { m.searchQuery += strings.ToLower(string(r)) m.applyFilterAndSort() m.cursor = 0 m.scrollOffset = 0 return m, nil } return m, nil } func (m PipelineModel) handleStatusPicker(msg tea.KeyMsg) (PipelineModel, tea.Cmd) { switch msg.String() { case "esc", "q": m.statusPicker = false 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 if app, ok := m.CurrentApp(); ok { newStatus := statusOptions[m.statusCursor] return m, func() tea.Msg { return PipelineUpdateStatusMsg{ CareerOpsPath: m.careerOpsPath, App: app, NewStatus: newStatus, } } } } return m, nil } // handlePDFPicker consumes keys while the PDF picker overlay is open. func (m PipelineModel) handlePDFPicker(msg tea.KeyMsg) (PipelineModel, tea.Cmd) { switch msg.String() { case "esc", "q": m.pdfPicker = false return m, nil case "down", "j": m.pdfCursor++ if m.pdfCursor >= len(m.pdfChoices) { m.pdfCursor = len(m.pdfChoices) - 1 } case "up", "k": m.pdfCursor-- if m.pdfCursor < 0 { m.pdfCursor = 0 } case "enter", "d": m.pdfPicker = false if m.pdfCursor >= 0 && m.pdfCursor < len(m.pdfChoices) { return m, m.openPDFCmd(m.pdfChoices[m.pdfCursor]) } } return m, nil } // handleColPicker consumes keys while the column picker overlay is open. func (m PipelineModel) handleColPicker(msg tea.KeyMsg) (PipelineModel, tea.Cmd) { switch msg.String() { case "esc", "q", "C": m.colPicker = false return m, nil case "down", "j": m.colPickerIdx++ if m.colPickerIdx >= len(optionalCols) { m.colPickerIdx = len(optionalCols) - 1 } case "up", "k": m.colPickerIdx-- if m.colPickerIdx < 0 { m.colPickerIdx = 0 } case " ": col := optionalCols[m.colPickerIdx] m.visibleCols[col.id] = !m.visibleCols[col.id] } return m, nil } // openPDFCmd emits a PipelineOpenPDFMsg for a root-relative PDF path. func (m PipelineModel) openPDFCmd(relPath string) tea.Cmd { fullPath := filepath.Join(m.careerOpsPath, filepath.FromSlash(relPath)) return func() tea.Msg { return PipelineOpenPDFMsg{Path: fullPath} } } func (m PipelineModel) loadCurrentReport() tea.Cmd { app, ok := m.CurrentApp() if !ok || app.ReportPath == "" { return nil } if _, cached := m.reportCache[app.ReportPath]; cached { return nil } path := m.careerOpsPath report := app.ReportPath return func() tea.Msg { return PipelineLoadReportMsg{CareerOpsPath: path, ReportPath: report} } } // matchesSearch reports whether app contains the query as a case-insensitive // substring of its company, role, or notes. Empty query matches everything. // Lowercases both sides so callers don't have to remember the contract. func matchesSearch(app model.CareerApplication, query string) bool { if query == "" { return true } q := strings.ToLower(query) if strings.Contains(strings.ToLower(app.Company), q) { return true } if strings.Contains(strings.ToLower(app.Role), q) { return true } if strings.Contains(strings.ToLower(app.Notes), q) { return true } return false } // applyFilterAndSort rebuilds the filtered list from apps. func (m *PipelineModel) applyFilterAndSort() { var filtered []model.CareerApplication currentFilter := pipelineTabs[m.activeTab].filter for _, app := range m.apps { if !matchesSearch(app, m.searchQuery) { continue } norm := data.NormalizeStatus(app.Status) switch currentFilter { case filterAll: filtered = append(filtered, app) case filterTop: if app.Score >= 4.0 && norm != "skip" { filtered = append(filtered, app) } default: if norm == currentFilter { filtered = append(filtered, app) } } } // Sort less := m.sortLess() sort.SliceStable(filtered, func(i, j int) bool { return less(filtered[i], filtered[j]) }) // In grouped mode, always sort by status priority first, then by selected sort within groups if m.viewMode == "grouped" { sort.SliceStable(filtered, func(i, j int) bool { pi := data.StatusPriority(filtered[i].Status) pj := data.StatusPriority(filtered[j].Status) if pi != pj { return pi < pj } // Within same group, use selected sort return less(filtered[i], filtered[j]) }) } m.filtered = filtered } // sortLess returns the comparator for the active sort mode. Shared by the flat // sort and the within-group tiebreaker in grouped view. func (m PipelineModel) sortLess() func(a, b model.CareerApplication) bool { switch m.sortMode { case sortDate: return func(a, b model.CareerApplication) bool { return a.Date > b.Date } case sortCompany: return func(a, b model.CareerApplication) bool { return strings.ToLower(a.Company) < strings.ToLower(b.Company) } case sortStatus: return func(a, b model.CareerApplication) bool { return data.StatusPriority(a.Status) < data.StatusPriority(b.Status) } case sortLocation: // Remote-first, then hybrid, then onsite; alphabetical city as tiebreaker. return func(a, b model.CareerApplication) bool { ra, rb := workModeRank(a.WorkMode), workModeRank(b.WorkMode) if ra != rb { return ra < rb } return a.Location < b.Location } case sortPay: // Highest band ceiling first; unknown pay (0) sinks to the bottom. return func(a, b model.CareerApplication) bool { return a.PayMax > b.PayMax } case sortLast: // Most recent contact first; empty dates sink to the bottom. return func(a, b model.CareerApplication) bool { return a.LastContact > b.LastContact } default: // sortScore return func(a, b model.CareerApplication) bool { return a.Score > b.Score } } } // workModeRank orders work modes remote-first for the location sort. func workModeRank(mode string) int { switch mode { case "Remote": return 0 case "RemoteFlex": return 1 case "Hybrid": return 2 case "Full": return 3 default: return 4 } } // chromeRowsFixed returns the number of fixed chrome rows above/below the body // (header + tabs(2) + metrics + sortbar + column header + help + 1 search bar // when active). Shared by View() and adjustScroll() so additions stay in sync. func (m PipelineModel) chromeRowsFixed() int { rows := 8 // header + tabs(2) + metrics + sortbar + column header + help + preview baseline if m.searchInput || m.searchQuery != "" { rows++ } return rows } // previewBudgetApprox is the approximate row count reserved for the preview block // when computing scroll positioning. View() measures the actual rendered preview // height; adjustScroll uses this constant to avoid re-rendering on every keystroke. const previewBudgetApprox = 6 // adjustScroll updates scrollOffset so the cursor stays visible. func (m *PipelineModel) adjustScroll() { availHeight := m.height - m.chromeRowsFixed() - previewBudgetApprox if availHeight < 5 { availHeight = 5 } line := m.cursorLineEstimate() margin := 3 if line >= m.scrollOffset+availHeight-margin { m.scrollOffset = line - availHeight + margin + 1 } if line < m.scrollOffset+margin { m.scrollOffset = line - margin } if m.scrollOffset < 0 { m.scrollOffset = 0 } } func (m PipelineModel) cursorLineEstimate() int { if m.viewMode != "grouped" { return m.cursor } // Account for group headers line := 0 prevStatus := "" for i, app := range m.filtered { norm := data.NormalizeStatus(app.Status) if norm != prevStatus { line++ // group header prevStatus = norm } if i == m.cursor { return line } line++ } return line } // -- View -- // View renders the pipeline screen. func (m PipelineModel) View() string { header := m.renderHeader() tabs := m.renderTabs() metricsBar := m.renderMetrics() sortBar := m.renderSortBar() searchBar := m.renderSearchBar() body := m.renderBody() preview := m.renderPreview() help := m.renderHelp() // Apply scroll to body bodyLines := strings.Split(body, "\n") if m.scrollOffset > 0 && m.scrollOffset < len(bodyLines) { bodyLines = bodyLines[m.scrollOffset:] } // Calculate available height for body previewLines := strings.Count(preview, "\n") + 1 availHeight := m.height - m.chromeRowsFixed() - previewLines if availHeight < 3 { availHeight = 3 } if len(bodyLines) > availHeight { bodyLines = bodyLines[:availHeight] } body = strings.Join(bodyLines, "\n") // Column picker overlay if m.colPicker { body = m.overlayColPicker(body) } // Status picker overlay if m.statusPicker { body = m.overlayStatusPicker(body) } // PDF picker overlay if m.pdfPicker { body = m.overlayPDFPicker(body) } sections := []string{header, tabs, metricsBar, sortBar} if searchBar != "" { sections = append(sections, searchBar) } sections = append(sections, m.renderColumnHeader(), body, preview, help) return lipgloss.JoinVertical(lipgloss.Left, sections...) } // renderSearchBar returns an empty string when there is no active or in-progress // search; otherwise it renders a vim-style status line showing the query and the // match count. While in input mode, a trailing cursor is appended. func (m PipelineModel) renderSearchBar() string { if !m.searchInput && m.searchQuery == "" { return "" } style := lipgloss.NewStyle(). Foreground(m.theme.Text). Width(m.width). Padding(0, 2) prompt := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Render("/") queryStyle := lipgloss.NewStyle().Foreground(m.theme.Text) hintStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext) display := queryStyle.Render(m.searchQuery) if m.searchInput { display += lipgloss.NewStyle().Foreground(m.theme.Blue).Render("█") } tabFiltered := m.countForFilter(pipelineTabs[m.activeTab].filter) matchInfo := hintStyle.Render(fmt.Sprintf(" %d/%d matching", len(m.filtered), tabFiltered)) hint := "" if m.searchInput { hint = hintStyle.Render(" Enter: keep Esc: cancel Ctrl+U: clear") } else { hint = hintStyle.Render(" Esc: clear /: edit") } return style.Render(prompt + " " + display + matchInfo + hint) } func (m PipelineModel) renderHeader() string { style := lipgloss.NewStyle(). Bold(true). Foreground(m.theme.Text). Background(m.theme.Surface). Width(m.width). Padding(0, 2) right := lipgloss.NewStyle().Foreground(m.theme.Subtext) avg := fmt.Sprintf("%.1f", m.metrics.AvgScore) info := right.Render(fmt.Sprintf("%d offers | Avg %s/5", m.metrics.Total, avg)) title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Render("CAREER PIPELINE") 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 PipelineModel) renderTabs() string { var tabs []string var underParts []string for i, tab := range pipelineTabs { // Count items for this tab count := m.countForFilter(tab.filter) label := fmt.Sprintf(" %s (%d) ", tab.label, count) if i == m.activeTab { style := lipgloss.NewStyle(). Bold(true). Foreground(m.theme.Blue). Padding(0, 0) tabs = append(tabs, style.Render(label)) underParts = append(underParts, strings.Repeat("━", lipgloss.Width(label))) } else { style := lipgloss.NewStyle(). Foreground(m.theme.Subtext). Padding(0, 0) tabs = append(tabs, style.Render(label)) underParts = append(underParts, strings.Repeat("─", lipgloss.Width(label))) } } row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) underline := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render(strings.Join(underParts, "")) padStyle := lipgloss.NewStyle().Padding(0, 1) return padStyle.Render(row) + "\n" + padStyle.Render(underline) } func (m PipelineModel) countForFilter(filter string) int { count := 0 for _, app := range m.apps { norm := data.NormalizeStatus(app.Status) switch filter { case filterAll: count++ case filterTop: if app.Score >= 4.0 && norm != "skip" { count++ } default: if norm == filter { count++ } } } return count } func (m PipelineModel) renderMetrics() string { style := lipgloss.NewStyle(). Background(m.theme.Surface). Width(m.width). Padding(0, 2) var parts []string statusColors := m.statusColorMap() for _, status := range statusGroupOrder { count, ok := m.metrics.ByStatus[status] if !ok || count == 0 { continue } color := statusColors[status] s := lipgloss.NewStyle().Foreground(color) parts = append(parts, s.Render(fmt.Sprintf("%s:%d", statusLabel(status), count))) } return style.Render(strings.Join(parts, " ")) } func (m PipelineModel) renderSortBar() string { style := lipgloss.NewStyle(). Foreground(m.theme.Subtext). Width(m.width). Padding(0, 2) sortLabel := fmt.Sprintf("[Sort: %s]", m.sortMode) viewLabel := fmt.Sprintf("[View: %s]", m.viewMode) count := fmt.Sprintf("%d shown", len(m.filtered)) return style.Render(fmt.Sprintf("%s %s %s", sortLabel, viewLabel, count)) } func (m PipelineModel) renderBody() string { if len(m.filtered) == 0 { emptyStyle := lipgloss.NewStyle(). Foreground(m.theme.Subtext). Padding(1, 2) return emptyStyle.Render("No offers match this filter") } var lines []string prevStatus := "" padStyle := lipgloss.NewStyle().Padding(0, 2) for i, app := range m.filtered { norm := data.NormalizeStatus(app.Status) // Group header in grouped mode if m.viewMode == "grouped" && norm != prevStatus { count := m.countByNormStatus(norm) headerStyle := lipgloss.NewStyle(). Bold(true). Foreground(m.theme.Subtext) lines = append(lines, padStyle.Render( headerStyle.Render(fmt.Sprintf("── %s (%d) %s", strings.ToUpper(statusLabel(norm)), count, strings.Repeat("─", max(0, m.width-30-len(statusLabel(norm)))))), )) prevStatus = norm } selected := i == m.cursor line := m.renderAppLine(app, selected) lines = append(lines, line) } return strings.Join(lines, "\n") } // colWidths holds per-column rune budgets for the table. type colWidths struct { num, score, company, status, role int // optional columns — 0 means the column is hidden date, loc, pay, rpt, pdf, last int } func (m PipelineModel) colVisible(id ColumnID) bool { if m.visibleCols == nil { // Fall back to default for callers before init (tests, etc.) for _, col := range optionalCols { if col.id == id { return col.onByDefault } } return false } return m.visibleCols[id] } func (m PipelineModel) columnWidths() colWidths { c := colWidths{num: 5, score: 5, company: 16, status: 12} if m.colVisible(ColDate) { c.date = 10 } if m.colVisible(ColLocation) { c.loc = 20 } if m.colVisible(ColPay) { c.pay = 16 } if m.colVisible(ColHasReport) { c.rpt = 4 } if m.colVisible(ColHasPDF) { c.pdf = 4 } if m.colVisible(ColLastContact) { c.last = 10 } fixed := c.num + c.score + c.date + c.company + c.status + c.loc + c.pay + c.rpt + c.pdf + c.last c.role = m.width - fixed - 14 // separators + outer padding if c.role < 15 { c.role = 15 } return c } func (m PipelineModel) workModeColor(mode string) lipgloss.Color { switch mode { case "Remote": return m.theme.Green case "RemoteFlex": return m.theme.Sky case "Hybrid": return m.theme.Yellow case "Full": return m.theme.Red default: return m.theme.Subtext } } func (m PipelineModel) renderLocCell(app model.CareerApplication, width int) string { text := app.WorkMode if app.Location != "" { if text != "" { text += " · " + app.Location } else { text = app.Location } } if text == "" { text = "—" } return lipgloss.NewStyle().Foreground(m.workModeColor(app.WorkMode)).Width(width).Render(truncateRunes(text, width)) } func (m PipelineModel) renderCheckCell(yes bool, width int) string { text := "—" color := m.theme.Subtext if yes { text = "✓" color = m.theme.Green } return lipgloss.NewStyle().Foreground(color).Width(width).Render(text) } // renderPayCell prefers the pay range parsed from notes and falls back to the // report-cache comp estimate (the pre-column behavior). POSTED bands render // green; estimates stay yellow. func (m PipelineModel) renderPayCell(app model.CareerApplication, width int) string { text := app.PayRange color := m.theme.Yellow if app.PaySource == "POSTED" { color = m.theme.Green } if text == "" { if summary, ok := m.reportCache[app.ReportPath]; ok && summary.comp != "" { text = summary.comp } } if text == "" { return lipgloss.NewStyle().Width(width).Render("") } return lipgloss.NewStyle().Foreground(color).Width(width).Render(truncateRunes(text, width-1)) } // renderColumnHeader labels the table columns; widths mirror renderAppLine. func (m PipelineModel) renderColumnHeader() string { cw := m.columnWidths() h := lipgloss.NewStyle().Foreground(m.theme.Subtext).Bold(true) cell := func(label string, width int) string { return h.Width(width).Render(truncateRunes(label, width)) } segments := []string{ cell("#", cw.num), h.Render("FIT"), // score cell is unpadded, always 3 runes wide } if cw.date > 0 { segments = append(segments, cell("APPLIED", cw.date)) } segments = append(segments, cell("COMPANY", cw.company)) segments = append(segments, cell("ROLE", cw.role)) segments = append(segments, cell("STATUS", cw.status)) if cw.loc > 0 { segments = append(segments, cell("LOCATION", cw.loc)) } if cw.pay > 0 { segments = append(segments, cell("PAY", cw.pay)) } if cw.rpt > 0 { segments = append(segments, cell("RPT", cw.rpt)) } if cw.pdf > 0 { segments = append(segments, cell("PDF", cw.pdf)) } if cw.last > 0 { segments = append(segments, cell("LAST", cw.last)) } padStyle := lipgloss.NewStyle().Padding(0, 2) return padStyle.Render(" " + strings.Join(segments, " ")) } func (m PipelineModel) renderAppLine(app model.CareerApplication, selected bool) string { padStyle := lipgloss.NewStyle().Padding(0, 2) cw := m.columnWidths() // Tracker number (fixed width) numText := "#—" if app.Number > 0 { numText = fmt.Sprintf("#%d", app.Number) } numStyle := lipgloss.NewStyle().Foreground(m.theme.Blue).Bold(true).Width(cw.num) // Score with color scoreStyle := m.scoreStyle(app.Score) score := scoreStyle.Render(fmt.Sprintf("%.1f", app.Score)) // Company (truncate) company := truncateRunes(app.Company, cw.company) companyStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Width(cw.company) // Date (fixed width) dateText := app.Date if dateText == "" { dateText = "—" } dateStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Width(cw.date) // Role (truncate) role := truncateRunes(app.Role, cw.role) roleStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Width(cw.role) // Status with color -- fixed column norm := data.NormalizeStatus(app.Status) statusColor := m.statusColorMap()[norm] statusStyle := lipgloss.NewStyle().Foreground(statusColor).Width(cw.status) statusText := statusStyle.Render(statusLabel(norm)) segments := []string{ numStyle.Render(truncateRunes(numText, cw.num)), score, } if cw.date > 0 { segments = append(segments, dateStyle.Render(truncateRunes(dateText, cw.date))) } segments = append(segments, companyStyle.Render(company)) segments = append(segments, roleStyle.Render(role)) segments = append(segments, statusText) if cw.loc > 0 { segments = append(segments, m.renderLocCell(app, cw.loc)) } if cw.pay > 0 { segments = append(segments, m.renderPayCell(app, cw.pay)) } if cw.rpt > 0 { segments = append(segments, m.renderCheckCell(app.ReportPath != "", cw.rpt)) } if cw.pdf > 0 { segments = append(segments, m.renderCheckCell(app.HasPDF, cw.pdf)) } if cw.last > 0 { lastText := "—" if app.LastContact != "" { lastText = formatTimeAgo(app.LastContact) } lastStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Width(cw.last) if app.LastContact != "" && app.LastContact != app.Date { lastStyle = lastStyle.Foreground(m.theme.Text) } segments = append(segments, lastStyle.Render(truncateRunes(lastText, cw.last))) } line := " " + strings.Join(segments, " ") if selected { selStyle := lipgloss.NewStyle(). Background(m.theme.Overlay). Width(m.width - 4) return padStyle.Render(selStyle.Render(line)) } return padStyle.Render(line) } func (m PipelineModel) renderPreview() string { app, ok := m.CurrentApp() if !ok { return "" } padStyle := lipgloss.NewStyle().Padding(0, 2) divider := lipgloss.NewStyle().Foreground(m.theme.Overlay) var lines []string lines = append(lines, padStyle.Render(divider.Render(strings.Repeat("─", m.width-4)))) labelStyle := lipgloss.NewStyle().Foreground(m.theme.Sky).Bold(true) valueStyle := lipgloss.NewStyle().Foreground(m.theme.Text) dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext) // Quick facts derived from notes — available even when there is no report, // and the only place narrow terminals see location/pay/last-contact. var facts []string if app.WorkMode != "" || app.Location != "" { loc := app.WorkMode if app.Location != "" { if loc != "" { loc += " · " + app.Location } else { loc = app.Location } } facts = append(facts, labelStyle.Render("Loc: ")+valueStyle.Render(loc)) } if app.PayRange != "" { pay := app.PayRange if app.PaySource != "" { pay += " (" + app.PaySource + ")" } facts = append(facts, labelStyle.Render("Pay: ")+valueStyle.Render(pay)) } if app.LastContact != "" { facts = append(facts, labelStyle.Render("Last contact: ")+ valueStyle.Render(fmt.Sprintf("%s (%s)", app.LastContact, formatTimeAgo(app.LastContact)))) } if len(facts) > 0 { lines = append(lines, padStyle.Render(strings.Join(facts, " "))) } outcome := previewOutcome(app) // Check report cache if summary, ok := m.reportCache[app.ReportPath]; ok { if summary.archetype != "" { lines = append(lines, padStyle.Render( labelStyle.Render("Archetype: ")+valueStyle.Render(summary.archetype))) } if summary.tldr != "" { lines = append(lines, padStyle.Render( labelStyle.Render("TL;DR: ")+valueStyle.Render(summary.tldr))) } if summary.comp != "" { lines = append(lines, padStyle.Render( labelStyle.Render("Comp: ")+valueStyle.Render(summary.comp))) } if summary.remote != "" { lines = append(lines, padStyle.Render( labelStyle.Render("Remote: ")+valueStyle.Render(summary.remote))) } } else if app.Notes != "" && outcome == "" { // Fallback: show notes (the outcome line below already carries them) notes := truncateRunes(app.Notes, m.width-10) lines = append(lines, padStyle.Render(dimStyle.Render(notes))) } else if outcome == "" { lines = append(lines, padStyle.Render(dimStyle.Render("Loading preview..."))) } // Closed-out postings: surface what happened as the last preview line. // The notes-only fallback above disappears once a report summary is // cached, which is exactly when the discard reason got lost (#787). if outcome != "" { // Width budget: 4 cols padding + 9 for the "Outcome: " label + slack, // mirroring the m.width-10 budget of the notes fallback above. lines = append(lines, padStyle.Render( labelStyle.Render("Outcome: ")+valueStyle.Render(truncateRunes(outcome, m.width-14)))) } return strings.Join(lines, "\n") } // previewOutcome returns "what happened" to a closed-out application — the raw // status (which often carries the decision date, e.g. "descartado 2026-03-12") // plus the tracker notes holding the reason. Returns "" for apps still in play. func previewOutcome(app model.CareerApplication) string { switch data.NormalizeStatus(app.Status) { case "discarded", "skip", "rejected": default: return "" } outcome := strings.TrimSpace(strings.ReplaceAll(app.Status, "**", "")) if app.Notes != "" { outcome += " — " + app.Notes } return outcome } func (m PipelineModel) 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) if m.flash != "" { flashStyle := lipgloss.NewStyle(). Foreground(m.theme.Yellow). Background(m.theme.Surface). Width(m.width). Padding(0, 1) return flashStyle.Render(m.flash) } if m.colPicker { return style.Render( keyStyle.Render("↑↓/jk") + descStyle.Render(" navigate ") + keyStyle.Render("SPACE") + descStyle.Render(" toggle ") + keyStyle.Render("Esc/C") + descStyle.Render(" close")) } if m.statusPicker || m.pdfPicker { return style.Render( keyStyle.Render("↑↓/jk") + descStyle.Render(" navigate ") + keyStyle.Render("Enter") + descStyle.Render(" confirm ") + keyStyle.Render("Esc") + descStyle.Render(" cancel")) } if m.searchInput { return style.Render( keyStyle.Render("type") + descStyle.Render(" filter live ") + keyStyle.Render("Enter") + descStyle.Render(" keep ") + keyStyle.Render("Ctrl+U") + descStyle.Render(" clear ") + keyStyle.Render("Esc") + descStyle.Render(" cancel")) } brand := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render("career-ops by santifer.io") keys := keyStyle.Render("↑↓/jk") + descStyle.Render(" nav ") + keyStyle.Render("←→/hl") + descStyle.Render(" tabs ") + keyStyle.Render("/") + descStyle.Render(" search ") + keyStyle.Render("s") + descStyle.Render(" sort ") + keyStyle.Render("r") + descStyle.Render(" refresh ") + keyStyle.Render("Enter") + descStyle.Render(" report ") + keyStyle.Render("o") + descStyle.Render(" open URL ") + keyStyle.Render("d") + descStyle.Render(" open PDF ") + keyStyle.Render("D") + descStyle.Render(" regen PDF ") + keyStyle.Render("c") + descStyle.Render(" change ") + keyStyle.Render("C") + descStyle.Render(" columns ") + keyStyle.Render("v") + descStyle.Render(" view ") + keyStyle.Render("p") + descStyle.Render(" progress ") + keyStyle.Render("q") + descStyle.Render(" quit") gap := m.width - lipgloss.Width(keys) - lipgloss.Width(brand) - 2 if gap < 1 { gap = 1 } return style.Render(keys + strings.Repeat(" ", gap) + brand) } func (m PipelineModel) overlayStatusPicker(body string) string { // Render status picker inline at bottom of body 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))) } // Append picker to body bodyLines = append(bodyLines, picker...) return strings.Join(bodyLines, "\n") } // overlayPDFPicker renders the PDF chooser inline at the bottom of the body, // mirroring overlayStatusPicker. Choices show the PDF filename only — the // directory is always output/ and the role variant lives in the name. func (m PipelineModel) overlayPDFPicker(body string) string { bodyLines := strings.Split(body, "\n") pickerWidth := m.width - 8 if pickerWidth < 30 { 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("Open CV PDF:"))) for i, choice := range m.pdfChoices { style := lipgloss.NewStyle().Foreground(m.theme.Text).Width(pickerWidth) if i == m.pdfCursor { style = style.Background(m.theme.Overlay).Bold(true) } prefix := " " if i == m.pdfCursor { prefix = "> " } name := truncateRunes(filepath.Base(filepath.FromSlash(choice)), pickerWidth-2) picker = append(picker, padStyle.Render(style.Render(prefix+name))) } bodyLines = append(bodyLines, picker...) return strings.Join(bodyLines, "\n") } // overlayColPicker renders the column visibility picker inline at the bottom // of the body. SPACE toggles the focused column; ESC or C closes. func (m PipelineModel) overlayColPicker(body string) string { bodyLines := strings.Split(body, "\n") pickerWidth := 36 padStyle := lipgloss.NewStyle().Padding(0, 2) borderStyle := lipgloss.NewStyle().Foreground(m.theme.Blue).Bold(true) dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext) var picker []string picker = append(picker, padStyle.Render(borderStyle.Render("─── Columns (SPACE toggle · ESC close) ───"))) for i, col := range optionalCols { on := m.visibleCols[col.id] check := "[ ]" checkColor := m.theme.Subtext if on { check = "[✓]" checkColor = m.theme.Green } style := lipgloss.NewStyle().Foreground(m.theme.Text).Width(pickerWidth) if i == m.colPickerIdx { style = style.Background(m.theme.Overlay).Bold(true) } checkStr := lipgloss.NewStyle().Foreground(checkColor).Render(check) label := col.header if col.hint != "" { label += " " + dimStyle.Render(col.hint) } row := checkStr + " " + label picker = append(picker, padStyle.Render(style.Render(row))) } bodyLines = append(bodyLines, picker...) return strings.Join(bodyLines, "\n") } // -- Helpers -- func (m PipelineModel) scoreStyle(score float64) lipgloss.Style { switch { case score >= 4.2: return lipgloss.NewStyle().Foreground(m.theme.Green).Bold(true) case score >= 3.8: return lipgloss.NewStyle().Foreground(m.theme.Yellow) case score >= 3.0: return lipgloss.NewStyle().Foreground(m.theme.Text) default: return lipgloss.NewStyle().Foreground(m.theme.Red) } } func (m PipelineModel) statusColorMap() map[string]lipgloss.Color { return map[string]lipgloss.Color{ "interview": m.theme.Green, "offer": m.theme.Green, "applied": m.theme.Sky, "responded": m.theme.Blue, "evaluated": m.theme.Text, "skip": m.theme.Red, "rejected": m.theme.Subtext, "discarded": m.theme.Subtext, } } func (m PipelineModel) countByNormStatus(status string) int { count := 0 for _, app := range m.filtered { if data.NormalizeStatus(app.Status) == status { count++ } } return count } // formatTimeAgo renders an ISO date as a relative duration in calendar days: // "today", "yesterday", or "Nd ago". Tracker dates are day-granular (no // time-of-day), so we never report sub-day hours — doing so would fabricate // precision the data doesn't have (e.g. an entry dated today would otherwise // read "13h ago" simply because it's 1pm, not because contact was 13h back). func formatTimeAgo(dateStr string) string { t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local) if err != nil { return dateStr // not a date — show it untouched rather than lie } now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) contactDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local) // Round to the nearest day so DST transitions don't skew the count. days := int(math.Round(today.Sub(contactDay).Hours() / 24)) switch { case days <= 0: return "today" case days == 1: return "yesterday" default: return fmt.Sprintf("%dd ago", days) } } // truncateRunes truncates a string to at most maxRunes runes, appending "..." if truncated. func truncateRunes(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { return s } if maxRunes <= 3 { return string(runes[:maxRunes]) } return string(runes[:maxRunes-3]) + "..." } func statusLabel(norm string) string { switch norm { case "interview": return "Interview" case "offer": return "Offer" case "responded": return "Responded" case "applied": return "Applied" case "evaluated": return "Evaluated" case "skip": return "Skip" case "rejected": return "Rejected" case "discarded": return "Discarded" default: return norm } }