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

> Learn how the Go dashboard TUI filters, sorts, and displays pipeline data using tab-based filters and Bubble Tea. Enhance your workflow with efficient data visualization.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: how-to-guide
- Published: 2026-06-12

---

**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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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.

```go
// 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"`

```go
// 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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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.

```go
// 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`

```go
// 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

```go
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

```go
// 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

```go
// 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`](https://github.com/santifer/career-ops/blob/main/dashboard/internal/ui/screens/pipeline.go), with supporting normalization in [`dashboard/internal/data/normalize.go`](https://github.com/santifer/career-ops/blob/main/dashboard/internal/data/normalize.go) and priority rules in [`dashboard/internal/data/status.go`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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.