# How to Build a Go Bubble Tea Dashboard TUI for Filtering and Visualizing Application Pipelines

> Learn to build a Go Bubble Tea dashboard TUI. Filter and visualize application pipelines with real-time sorting and search. Discover interactive table features and aggregate metrics for your career ops.

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

---

**The Career-Ops dashboard uses the Bubble Tea state-machine framework and Lip-gloss styling to load an [`applications.md`](https://github.com/santifer/career-ops/blob/main/applications.md) tracker, compute aggregate metrics, and render an interactive table supporting real-time filtering, sorting, and search.**

The santifer/career-ops repository implements a terminal-user-interface (TUI) for managing job application pipelines. This guide examines how the Go Bubble Tea dashboard TUI parses tracker data, applies dynamic filters across multiple dimensions, and visualizes pipeline metrics through responsive table views and styled components.

## Core Architecture

The application follows a layered architecture where data loading, state management, and rendering remain strictly separated. Understanding this structure is essential for extending the filtering and visualization capabilities.

### Component Overview

The entry point in [`dashboard/main.go`](https://github.com/santifer/career-ops/blob/main/dashboard/main.go) bootstraps the system by parsing the `-path` flag, loading application data, and initializing the top-level `appModel`. This model coordinates three sub-models: `pipeline` (the table view), `viewer` (full-report view), and `progress` (analytics view). The `pipeline` sub-model, defined in [`dashboard/internal/ui/screens/pipeline.go`](https://github.com/santifer/career-ops/blob/main/dashboard/internal/ui/screens/pipeline.go), implements the `PipelineModel` struct that handles all logic for loading data, applying filters, sorting, pagination, and search.

Key data structures reside in [`dashboard/internal/model/career.go`](https://github.com/santifer/career-ops/blob/main/dashboard/internal/model/career.go), where the `CareerApplication` struct represents a single row with fields for company, role, status, score, and date. The `data` package handles persistence, parsing [`applications.md`](https://github.com/santifer/career-ops/blob/main/applications.md) through functions like `data.ParseApplications` and normalizing statuses via `data.NormalizeStatus`.

### Data Flow

The initialization sequence follows a strict pipeline:

1. **Load** – `data.ParseApplications` reads the tracker file into a slice of `CareerApplication` structs
2. **Metrics** – `data.ComputeMetrics` and `data.ComputeProgressMetrics` generate `PipelineMetrics` and `ProgressMetrics` aggregates
3. **Model Init** – `screens.NewPipelineModel(theme, apps, metrics, repoPath, width, height)` constructs the view model
4. **Event Loop** – `appModel.Update` receives key events, routes them to the active sub-model, and handles custom messages like `PipelineRefreshMsg` and `PipelineOpenReportMsg`
5. **Render** – `appModel.View` delegates to the active sub-model's `View()` method

## Filtering and Sorting Logic

The Go Bubble Tea dashboard TUI implements sophisticated filtering through a combination of tab-based status filters, live search queries, and multi-field sorting.

### Tab-Based Status Filters

The `pipelineTabs` slice (lines 87–96 in [`pipeline.go`](https://github.com/santifer/career-ops/blob/main/pipeline.go)) defines the available filter categories:

```go
var pipelineTabs = []pipelineTab{
    {filterAll, "ALL"},
    {filterEvaluated, "EVALUATED"},
    {filterApplied, "APPLIED"},
    {filterInterview, "INTERVIEW"},
    {filterTop, "TOP ≥4"},
    {filterSkip, "SKIP"},
    {filterRejected, "REJECTED"},
    {filterDiscarded, "DISCARDED"},
}

```

When the user presses **←/→** (or **h/l**), the `handleKey` method updates `activeTab` and triggers `applyFilterAndSort()` (lines 137–141). This function iterates over all applications, performing two-stage validation:

1. **Search Match** – `matchesSearch` (lines 17–35) checks if the application meets the current search query
2. **Status Filter** – `data.NormalizeStatus` canonicalizes the status field, then a switch statement filters based on the active tab

For the **TOP ≥4** filter, the logic specifically checks `if app.Score >= 4.0 && norm != "skip"` to ensure high-scoring applications are not hidden by skip status.

### Multi-Field Sorting

The `sortCycle` array (lines 98–99) enumerates available sort modes. Pressing **s** cycles through Score, Date, Company, and Location sorts. The `sortLess` function (lines 83–115) returns a comparator based on `sortMode`:

- **Score**: Descending numeric order (`a.Score > b.Score`)
- **Date**: Chronological reverse (`a.Date > b.Date`)
- **Company**: Case-insensitive lexical comparison
- **Location**: Work mode priority (remote → hybrid → onsite) followed by alphabetical city

The system uses `sort.SliceStable` twice: first for the flat list, then a secondary stable sort for grouped view (lines 62–78).

### Live Search Implementation

Pressing **/** opens the search input mode. While `searchInput` is active, the `handleSearchInput` method consumes keystrokes, updates `searchQuery`, and calls `applyFilterAndSort` (lines 22–64). The UI displays a live count via `renderSearchBar` (lines 31–63). To prevent filesystem thrashing, report previews only reload after the user commits the query with **Enter** or cancels with **Esc**, as noted in the implementation comments (lines 17–20).

## Visual Rendering Pipeline

The dashboard uses **Lip-gloss** for all terminal styling, creating a consistent visual language across the interface.

### Lip-gloss Styling System

The [`dashboard/internal/theme/theme.go`](https://github.com/santifer/career-ops/blob/main/dashboard/internal/theme/theme.go) package supplies colour palettes (e.g., `catppuccin`, `catppuccin-mocha`) that drive the visual appearance. These themes are injected into the `PipelineModel` during initialization and applied throughout the rendering methods.

### Table Layout Components

The `View()` method composes six distinct sections:

- **Header** (`renderHeader`, lines 66–84): Displays "CAREER PIPELINE" with total offer count and average score
- **Tabs** (`renderTabs`, lines 87–112): Shows filter tabs with dynamic counts per status
- **Metrics Bar** (`renderMetrics`, lines 39–58): Status-wise counts with colour coding
- **Sort Bar** (`renderSortBar`, lines 61–71): Current sort mode, view mode, and item count
- **Column Headers** (`renderColumnHeader`, lines 80–106): Adaptive titles based on terminal width
- **Body Rows** (`renderAppLine`, lines 8–78): Individual application rows with colour-coded scores, statuses, and pay ranges

The **grouped view** (`viewMode == "grouped"`) inserts status headers before each block (lines 89–102). Scroll handling via `adjustScroll` and `cursorLineEstimate` ensures the cursor remains visible when group headers shift line numbers.

## Interactive Features

Beyond passive visualization, the TUI supports direct manipulation of application data.

### Status Updates

Pressing **c** triggers the `statusPicker` overlay (lines 66–80). When the user selects a new status, the system emits a `PipelineUpdateStatusMsg` (lines 69–74), which persists the change via `data.UpdateApplicationStatus`. This updates the underlying [`applications.md`](https://github.com/santifer/career-ops/blob/main/applications.md) file without requiring a restart.

### Report Preview Loading

When a selected row contains a `ReportPath`, the `loadCurrentReport` method (lines 2–15) emits a `PipelineLoadReportMsg`. The main `appModel.Update` handler reads the report summary using `data.LoadReportSummary` and caches it in `PipelineModel.reportCache` (lines 69–73), enabling instant preview rendering.

## Summary

- The **Go Bubble Tea dashboard TUI** in santifer/career-ops uses a state-machine architecture with separate models for data, UI state, and rendering
- **Filtering** combines tab-based status selection via `applyFilterAndSort` with live search through `matchesSearch` and `handleSearchInput`
- **Sorting** supports multiple fields (Score, Date, Company, Location) using stable sorts and custom comparators in `sortLess`
- **Visualization** relies on Lip-gloss styling from the `theme` package, with components like `renderHeader`, `renderTabs`, and `renderAppLine` building the terminal interface
- **Interactivity** includes status updates persisted to disk and lazy-loaded report previews cached in `reportCache`

## Frequently Asked Questions

### How does the Career-Ops TUI handle real-time search filtering?

The system enters search mode when the user presses **/**, activating `handleSearchInput` to capture keystrokes. Each keystroke updates `searchQuery` and immediately calls `applyFilterAndSort`, which re-evaluates the full dataset against both the search string and active tab filter. The UI shows a live count in the search bar, though filesystem reads for report previews are deferred until the user commits the query with **Enter**.

### What sorting options are available in the pipeline view?

Pressing **s** cycles through four sort modes defined in `sortCycle`: Score (descending), Date (most recent first), Company (alphabetical), and Location (by work mode priority then city). The `sortLess` function in [`dashboard/internal/ui/screens/pipeline.go`](https://github.com/santifer/career-ops/blob/main/dashboard/internal/ui/screens/pipeline.go) implements the comparison logic, using `sort.SliceStable` to maintain consistent ordering within equal elements.

### How does the dashboard update application statuses without restarting?

When the user presses **c** and selects a status from the `statusPicker` overlay, the system creates a `PipelineUpdateStatusMsg` containing the application ID and new status. The main model's `Update` method handles this message by calling `data.UpdateApplicationStatus`, which writes the change back to [`applications.md`](https://github.com/santifer/career-ops/blob/main/applications.md). The `PipelineModel` then refreshes its local data slice to reflect the update immediately.

### Can the TUI display reports for specific applications?

Yes, when an application has a `ReportPath` field populated, pressing the selection key triggers `loadCurrentReport`, which emits a `PipelineLoadReportMsg`. The system reads the report summary via `data.LoadReportSummary` and stores it in `PipelineModel.reportCache`. This cached content renders in the preview panel (via `renderPreview`) without blocking the main event loop, allowing users to scan report content before opening the full viewer with **Enter**.