Files
redlineo d295c7f139
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s
init
2026-07-13 10:14:00 +03:00

1727 lines
47 KiB
Go

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
}
}