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:
62
cmd/tui_model_test.go
Normal file
62
cmd/tui_model_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user