Rewrite TUI as full-screen Bubble Tea two-pane interactive browser

Replace the line-based stdin pagination loop with a charmbracelet/bubbletea
application providing a responsive two-pane layout: a filterable deal list
on the left and a scrollable detail viewport on the right.

Key capabilities:

- Async startup: spinner + skeleton while store/deals are fetched, then
  transition to the interactive view. Fatal load errors propagate cleanly.
- Grouped sections: deals are bucketed by category (BOGO first, then by
  count descending) with numbered section headers. Jump with [/] for
  prev/next and 1-9 for direct section access.
- Inline filter cycling: s (sort: relevance/savings/ending), g (bogo
  toggle), c (category cycle), a (department cycle), l (limit cycle),
  r (reset to CLI-start defaults). Filters rebuild the list while
  preserving cursor position via stable IDs.
- Fuzzy search: / activates bubbletea's built-in list filter. Section
  jumps are disabled while a fuzzy filter is active.
- Detail pane: full deal metadata with word-wrapped text, scrollable
  via j/k, u/d (half-page), b/f (full page). Tab switches focus.
- Terminal size awareness: minimum 92x24, graceful too-small message.
- JSON mode: tui --json fetches and filters without launching the
  interactive UI, consistent with other commands.

New files:
  cmd/tui_model.go      — Bubble Tea model, view, update, grouping logic
  cmd/tui_model_test.go — Tests for sort canonicalization, group ordering,
                           and category choice building

Dependencies added: charmbracelet/bubbles, charmbracelet/bubbletea.
Transitive deps upgraded: lipgloss, x/ansi, x/cellbuf, x/term, go-colorful,
go-runewidth, sys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 01:32:10 -05:00
parent f486150c06
commit da327fe759
5 changed files with 1352 additions and 125 deletions

62
cmd/tui_model_test.go Normal file
View File

@@ -0,0 +1,62 @@
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tayloree/publix-deals/internal/api"
)
func strPtr(value string) *string { return &value }
func TestCanonicalSortMode(t *testing.T) {
assert.Equal(t, "savings", canonicalSortMode("savings"))
assert.Equal(t, "ending", canonicalSortMode("end"))
assert.Equal(t, "ending", canonicalSortMode("expiry"))
assert.Equal(t, "ending", canonicalSortMode("expiration"))
assert.Equal(t, "", canonicalSortMode("relevance"))
assert.Equal(t, "", canonicalSortMode("unknown"))
}
func TestBuildGroupedListItems_BogoFirstAndNumberedHeaders(t *testing.T) {
deals := []api.SavingItem{
{ID: "1", Title: strPtr("Bananas"), Categories: []string{"produce"}},
{ID: "2", Title: strPtr("Chicken"), Categories: []string{"meat", "bogo"}},
{ID: "3", Title: strPtr("Apples"), Categories: []string{"produce"}},
{ID: "4", Title: strPtr("Ground Beef"), Categories: []string{"meat"}},
}
items, starts := buildGroupedListItems(deals)
assert.NotEmpty(t, items)
assert.Equal(t, []int{0, 2, 5}, starts)
header, ok := items[0].(tuiGroupItem)
assert.True(t, ok)
assert.Equal(t, "BOGO", header.name)
assert.Equal(t, 1, header.ordinal)
header2, ok := items[2].(tuiGroupItem)
assert.True(t, ok)
assert.Equal(t, "Produce", header2.name)
assert.Equal(t, 2, header2.count)
header3, ok := items[5].(tuiGroupItem)
assert.True(t, ok)
assert.Equal(t, "Meat", header3.name)
assert.Equal(t, 1, header3.count)
}
func TestBuildCategoryChoices_AlwaysIncludesCurrent(t *testing.T) {
deals := []api.SavingItem{
{Categories: []string{"produce"}},
{Categories: []string{"meat"}},
}
choices := buildCategoryChoices(deals, "seafood")
assert.Contains(t, choices, "")
assert.Contains(t, choices, "produce")
assert.Contains(t, choices, "meat")
assert.Contains(t, choices, "seafood")
}