How the Go-Based Dashboard TUI Filters, Sorts, and Displays Pipeline Data

The Career-Ops dashboard uses an in-memory pipeline that applies tab-based filters and comparators to a slice of applications, then renders the filtered results using Bubble Tea and Lip Gloss components.

The santifer/career-ops repository implements a terminal user interface (TUI) for tracking job applications. At the heart of this Go-based dashboard lies a deterministic data pipeline in dashboard/internal/ui/screens/pipeline.go that transforms raw application data into a rendered view through a sequence of filtering, sorting, and formatting operations.

Core Data Flow Architecture

The pipeline follows a unidirectional data flow where user interactions trigger recomputation of a derived slice that drives the entire display.

Model Initialization

When the dashboard launches, NewPipelineModel (lines 27-43 in dashboard/internal/ui/screens/pipeline.go) receives the raw application slice ([]model.CareerApplication) and initializes the UI state with default values: sortMode = sortScore, activeTab = 0 (filterAll), and viewMode = "grouped". Immediately after construction, it invokes applyFilterAndSort() to populate the filtered slice that the renderer consumes.

// From dashboard/internal/ui/screens/pipeline.go
m := screens.NewPipelineModel(theme, apps, metrics, repoPath, width, height)
// Internally calls applyFilterAndSort() to initialize m.filtered

User Interaction Handling

The Update method routes keyboard events to specialized handlers. Key presses trigger state mutations followed by a recomputation of the filtered dataset:

  • s – Cycles through sortCycle constants (sortScore → sortDate → sortCompany → etc.)
  • ←/→ (left/right) – Switches activeTab index in pipelineTabs
  • / – Toggles the live-search input mode
  • c – Opens the status picker using statusOptions

After any state change, the handlers invoke applyFilterAndSort() (lines 99-122) to refresh the filtered slice.

The Filtering and Sorting Pipeline

The applyFilterAndSort() function implements a two-stage pipeline:

  1. Filtering – Iterates over m.apps and appends items to a temporary slice only if they match the current tab filter and the live search query
  2. Sorting – Applies a comparator selected by sortMode, then optionally re-sorts by status priority if viewMode == "grouped"
// Conceptual flow from dashboard/internal/ui/screens/pipeline.go#L37-L78
func (m *PipelineModel) applyFilterAndSort() {
    var filtered []model.CareerApplication
    // Filter phase
    for _, app := range m.apps {
        if matchesSearch(app, m.searchQuery) && matchesTab(app, m.activeTab) {
            filtered = append(filtered, app)
        }
    }
    // Sort phase
    sort.Slice(filtered, m.sortLess())
    m.filtered = filtered
}

Filtering Implementation Details

The filtering logic combines tab-based categorization with substring search across multiple fields.

Tab-Based Status Filtering

The dashboard defines eight filter constants in dashboard/internal/ui/screens/pipeline.go (lines 70-79): filterAll, filterEvaluated, filterApplied, filterInterview, filterSkip, filterRejected, filterDiscarded, and filterTop. The pipelineTabs slice maps these constants to human-readable labels.

When activeTab selects a specific status, the filter uses data.NormalizeStatus() (from dashboard/internal/data/normalize.go) to canonicalize free-form status strings (e.g., "Interviewed" → interview) before comparison. The special filterTop case accepts applications with Score >= 4.0 that are not marked as skip.

Live Search Implementation

The matchesSearch helper performs a case-insensitive substring check across Company, Role, and Notes fields. An empty search query matches all records, ensuring the filter remains permissive by default.

// Logic derived from dashboard/internal/ui/screens/pipeline.go filtering loop
func matchesSearch(app model.CareerApplication, query string) bool {
    if query == "" { return true }
    q := strings.ToLower(query)
    return strings.Contains(strings.ToLower(app.Company), q) ||
           strings.Contains(strings.ToLower(app.Role), q) ||
           strings.Contains(strings.ToLower(app.Notes), q)
}

Sorting Algorithms and Comparators

Sorting behavior is determined by the sortLess() factory function (lines 85-115), which returns a closure based on the current sortMode.

Available Sort Modes

The system supports seven sort constants defined at lines 59-67:

  • sortScore (default) – Descending by app.Score
  • sortDate – Descending by application date (app.Date)
  • sortCompany – Ascending alphabetic by Company (case-insensitive)
  • sortStatus – Ascending by data.StatusPriority(app.Status)
  • sortLocation – Remote-first ordering using workModeRank, then by Location
  • sortPay – Descending by app.PayMax
  • sortLast – Descending by app.LastContact
// Comparator example from dashboard/internal/ui/screens/pipeline.go
case sortLocation:
    return func(a, b model.CareerApplication) bool {
        ra, rb := workModeRank(a.WorkMode), workModeRank(b.WorkMode)
        if ra != rb { return ra < rb } // Remote (0) < Hybrid (1) < Onsite (2)
        return a.Location < b.Location
    }

Grouped View Sorting

When viewMode == "grouped", the implementation performs a secondary sort by data.StatusPriority after applying the primary comparator. This ensures applications cluster by status (e.g., all "Interview" items together) while maintaining the chosen sort order within each block.

Rendering the Terminal Interface

The View() method (lines 74-102) constructs the terminal output by concatenating multiple Lip Gloss-styled components: renderHeader(), renderTabs(), renderMetrics(), renderSortBar(), renderSearchBar(), renderColumnHeader(), renderBody(), renderPreview(), and renderHelp().

Column-Dynamic Layout

renderColumnHeader calculates columnWidths based on terminal width, truncating or hiding columns responsively. The body renderer iterates over m.filtered, calling renderAppLine() for each row, which applies color coding to scores (high/green, low/red) and status badges.

Grouped View Headers

In grouped mode, renderBody detects status transitions and injects block headers formatted as ── INTERVIEW (3) before the first row of each status category. This creates visual separation without requiring additional UI chrome.

Practical Code Examples

Initializing the Pipeline Model

package main

import (
    "github.com/charmbracelet/bubbletea"
    "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"
)

func main() {
    // Load data from tracker
    var apps []model.CareerApplication
    var metrics model.PipelineMetrics
    
    // Initialize with Catppuccin Latte theme
    th := theme.NewCatppuccinLatte() // dashboard/internal/theme/catppuccin_latte.go
    
    // Create model (width/height updated by Bubble Tea framework)
    m := screens.NewPipelineModel(th, apps, metrics, "/repo/path", 100, 30)
    
    p := tea.NewProgram(m)
    if err := p.Start(); err != nil {
        panic(err)
    }
}

Changing Sort Order Programmatically

// Access the model and change sort mode
m.sortMode = sortPay // Constant from dashboard/internal/ui/screens/pipeline.go
m.applyFilterAndSort() // Recompute filtered slice
// UI updates automatically on next render tick

Filtering Without the UI

// Programmatically show only evaluated applications with high scores
m.activeTab = 1 // Index for filterEvaluated in pipelineTabs
m.searchQuery = ""
m.applyFilterAndSort()

for _, app := range m.filtered {
    if app.Score >= 4.0 {
        fmt.Printf("%s: %.1f\n", app.Company, app.Score)
    }
}

Summary

  • Data Flow – NewPipelineModel initializes state and calls applyFilterAndSort() to create a derived filtered slice that powers the entire UI
  • Filtering – Combines tab-based status filtering (using data.NormalizeStatus) with case-insensitive substring search across company, role, and notes
  • Sorting – sortLess() provides seven comparators; grouped view applies a secondary sort by data.StatusPriority to maintain status blocks
  • Rendering – Lip Gloss components read from m.filtered, with dynamic column widths and grouped headers inserted in renderBody()
  • Key Files – Core logic resides in dashboard/internal/ui/screens/pipeline.go, with supporting normalization in dashboard/internal/data/normalize.go and priority rules in dashboard/internal/data/status.go

Frequently Asked Questions

How does the Career-Ops dashboard handle live search queries?

The dashboard calls matchesSearch() inside applyFilterAndSort() on every keystroke when search mode is active. This function performs a case-insensitive substring match against Company, Role, and Notes fields. Because the operation runs on the in-memory apps slice (typically small), updates are instantaneous and do not require debouncing or async processing.

What sorting options are available in the pipeline view?

The TUI supports seven sort modes defined as constants in dashboard/internal/ui/screens/pipeline.go: sortScore (default), sortDate, sortCompany, sortStatus, sortLocation, sortPay, and sortLast. Users cycle through these by pressing the s key, following the order defined in the sortCycle slice.

How does the grouped view mode organize applications?

When viewMode is set to "grouped", the system first applies the selected comparator to sort applications globally, then performs a secondary sort by data.StatusPriority. This ensures items cluster by status (Applied, Interview, etc.) while maintaining the chosen sort order within each status block. The renderer injects status headers (e.g., ── INTERVIEW (3)) before each group.

Can I use the filtering logic outside the TUI?

Yes. The applyFilterAndSort() method and its helpers (matchesSearch, NormalizeStatus) are pure functions that operate on []model.CareerApplication. You can instantiate a PipelineModel programmatically, set activeTab and searchQuery fields, call applyFilterAndSort(), and then read the filtered slice for server-side rendering, report generation, or CLI export without launching the Bubble Tea interface.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →