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 throughsortCycleconstants (sortScore→sortDate→sortCompany→ etc.)←/→(left/right) – SwitchesactiveTabindex inpipelineTabs/– Toggles the live-search input modec– Opens the status picker usingstatusOptions
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:
- Filtering – Iterates over
m.appsand appends items to a temporary slice only if they match the current tab filter and the live search query - Sorting – Applies a comparator selected by
sortMode, then optionally re-sorts by status priority ifviewMode == "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 byapp.ScoresortDate– Descending by application date (app.Date)sortCompany– Ascending alphabetic byCompany(case-insensitive)sortStatus– Ascending bydata.StatusPriority(app.Status)sortLocation– Remote-first ordering usingworkModeRank, then byLocationsortPay– Descending byapp.PayMaxsortLast– Descending byapp.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 –
NewPipelineModelinitializes state and callsapplyFilterAndSort()to create a derivedfilteredslice 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 bydata.StatusPriorityto maintain status blocks - Rendering – Lip Gloss components read from
m.filtered, with dynamic column widths and grouped headers inserted inrenderBody() - Key Files – Core logic resides in
dashboard/internal/ui/screens/pipeline.go, with supporting normalization indashboard/internal/data/normalize.goand priority rules indashboard/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →