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>
1129 lines
27 KiB
Go
1129 lines
27 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/tayloree/publix-deals/internal/api"
|
|
"github.com/tayloree/publix-deals/internal/filter"
|
|
)
|
|
|
|
const (
|
|
minTUIWidth = 92
|
|
minTUIHeight = 24
|
|
)
|
|
|
|
var (
|
|
tuiHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
|
tuiMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
|
tuiHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
|
tuiValueStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229"))
|
|
tuiBogoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
|
|
tuiDealStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229"))
|
|
tuiMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
|
tuiSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81"))
|
|
)
|
|
|
|
type tuiLoadConfig struct {
|
|
ctx context.Context
|
|
storeNumber string
|
|
zipCode string
|
|
initialOpts filter.Options
|
|
}
|
|
|
|
type tuiDataLoadedMsg struct {
|
|
storeLabel string
|
|
allDeals []api.SavingItem
|
|
initialOpts filter.Options
|
|
}
|
|
|
|
type tuiDataLoadErrMsg struct {
|
|
err error
|
|
}
|
|
|
|
type tuiFocus int
|
|
|
|
const (
|
|
tuiFocusList tuiFocus = iota
|
|
tuiFocusDetail
|
|
)
|
|
|
|
type tuiGroupItem struct {
|
|
name string
|
|
count int
|
|
ordinal int
|
|
}
|
|
|
|
func (g tuiGroupItem) FilterValue() string { return strings.ToLower(g.name) }
|
|
func (g tuiGroupItem) Title() string { return fmt.Sprintf("%d. %s", g.ordinal, g.name) }
|
|
func (g tuiGroupItem) Description() string {
|
|
return fmt.Sprintf("Section header • %d deals", g.count)
|
|
}
|
|
|
|
type tuiDealItem struct {
|
|
deal api.SavingItem
|
|
group string
|
|
title string
|
|
description string
|
|
filterValue string
|
|
}
|
|
|
|
func (d tuiDealItem) FilterValue() string { return d.filterValue }
|
|
func (d tuiDealItem) Title() string { return d.title }
|
|
func (d tuiDealItem) Description() string { return d.description }
|
|
|
|
type dealsTUIModel struct {
|
|
loading bool
|
|
spinner spinner.Model
|
|
loadCmd tea.Cmd
|
|
fatalErr error
|
|
|
|
storeLabel string
|
|
allDeals []api.SavingItem
|
|
|
|
opts filter.Options
|
|
initialOpts filter.Options
|
|
|
|
sortChoices []string
|
|
sortIndex int
|
|
categoryChoices []string
|
|
categoryIndex int
|
|
departmentChoices []string
|
|
departmentIndex int
|
|
limitChoices []int
|
|
limitIndex int
|
|
|
|
list list.Model
|
|
detail viewport.Model
|
|
|
|
focus tuiFocus
|
|
showHelp bool
|
|
selectedID string
|
|
|
|
groupStarts []int
|
|
visibleDeals int
|
|
|
|
width, height int
|
|
bodyHeight int
|
|
listPaneWidth int
|
|
detailPaneWidth int
|
|
tooSmall bool
|
|
}
|
|
|
|
func newLoadingDealsTUIModel(cfg tuiLoadConfig) dealsTUIModel {
|
|
delegate := list.NewDefaultDelegate()
|
|
delegate.SetHeight(2)
|
|
delegate.SetSpacing(1)
|
|
|
|
lst := list.New([]list.Item{}, delegate, 0, 0)
|
|
lst.Title = "Deals"
|
|
lst.SetStatusBarItemName("item", "items")
|
|
lst.SetShowStatusBar(true)
|
|
lst.SetFilteringEnabled(true)
|
|
lst.SetShowHelp(false)
|
|
lst.SetShowPagination(true)
|
|
lst.DisableQuitKeybindings()
|
|
|
|
detail := viewport.New(0, 0)
|
|
detail.KeyMap.PageDown.SetKeys("f", "pgdown")
|
|
detail.KeyMap.PageUp.SetKeys("b", "pgup")
|
|
detail.KeyMap.HalfPageDown.SetKeys("d")
|
|
detail.KeyMap.HalfPageUp.SetKeys("u")
|
|
|
|
spin := spinner.New()
|
|
spin.Spinner = spinner.Dot
|
|
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
|
|
|
|
return dealsTUIModel{
|
|
loading: true,
|
|
spinner: spin,
|
|
loadCmd: loadTUIDataCmd(cfg),
|
|
initialOpts: cfg.initialOpts,
|
|
opts: cfg.initialOpts,
|
|
list: lst,
|
|
detail: detail,
|
|
focus: tuiFocusList,
|
|
}
|
|
}
|
|
|
|
func loadTUIDataCmd(cfg tuiLoadConfig) tea.Cmd {
|
|
return func() tea.Msg {
|
|
_, storeLabel, allDeals, err := loadTUIData(cfg.ctx, cfg.storeNumber, cfg.zipCode)
|
|
if err != nil {
|
|
return tuiDataLoadErrMsg{err: err}
|
|
}
|
|
return tuiDataLoadedMsg{
|
|
storeLabel: storeLabel,
|
|
allDeals: allDeals,
|
|
initialOpts: cfg.initialOpts,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m dealsTUIModel) Init() tea.Cmd {
|
|
return tea.Batch(m.spinner.Tick, m.loadCmd)
|
|
}
|
|
|
|
func (m dealsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.resize()
|
|
return m, nil
|
|
|
|
case tuiDataLoadedMsg:
|
|
m.loading = false
|
|
m.storeLabel = msg.storeLabel
|
|
m.allDeals = msg.allDeals
|
|
m.initialOpts = canonicalizeTUIOptions(msg.initialOpts)
|
|
m.opts = m.initialOpts
|
|
m.initializeInlineChoices()
|
|
m.applyCurrentFilters(true)
|
|
m.resize()
|
|
return m, nil
|
|
|
|
case tuiDataLoadErrMsg:
|
|
m.loading = false
|
|
m.fatalErr = msg.err
|
|
return m, tea.Quit
|
|
|
|
case spinner.TickMsg:
|
|
if m.loading {
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
keyMsg, isKey := msg.(tea.KeyMsg)
|
|
if isKey {
|
|
if keyMsg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
if m.loading {
|
|
if keyMsg.String() == "q" {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if m.loading {
|
|
return m, nil
|
|
}
|
|
|
|
if isKey {
|
|
filtering := m.list.FilterState() == list.Filtering
|
|
key := keyMsg.String()
|
|
|
|
switch key {
|
|
case "q":
|
|
if !filtering {
|
|
return m, tea.Quit
|
|
}
|
|
case "tab":
|
|
if !filtering {
|
|
if m.focus == tuiFocusList {
|
|
m.focus = tuiFocusDetail
|
|
} else {
|
|
m.focus = tuiFocusList
|
|
}
|
|
return m, nil
|
|
}
|
|
case "esc":
|
|
if m.focus == tuiFocusDetail && !filtering {
|
|
m.focus = tuiFocusList
|
|
return m, nil
|
|
}
|
|
case "?":
|
|
if !filtering {
|
|
m.showHelp = !m.showHelp
|
|
m.resize()
|
|
return m, nil
|
|
}
|
|
case "s":
|
|
if !filtering {
|
|
m.cycleSortMode()
|
|
return m, nil
|
|
}
|
|
case "g":
|
|
if !filtering {
|
|
m.opts.BOGO = !m.opts.BOGO
|
|
m.applyCurrentFilters(false)
|
|
return m, nil
|
|
}
|
|
case "c":
|
|
if !filtering {
|
|
m.cycleCategory()
|
|
return m, nil
|
|
}
|
|
case "a":
|
|
if !filtering {
|
|
m.cycleDepartment()
|
|
return m, nil
|
|
}
|
|
case "l":
|
|
if !filtering {
|
|
m.cycleLimit()
|
|
return m, nil
|
|
}
|
|
case "r":
|
|
if !filtering {
|
|
m.opts = m.initialOpts
|
|
m.syncChoiceIndexesFromOptions()
|
|
m.applyCurrentFilters(false)
|
|
return m, nil
|
|
}
|
|
case "]":
|
|
if !filtering {
|
|
if m.list.IsFiltered() {
|
|
return m, m.list.NewStatusMessage("Clear fuzzy filter before section jumps.")
|
|
}
|
|
m.jumpSection(1)
|
|
return m, nil
|
|
}
|
|
case "[":
|
|
if !filtering {
|
|
if m.list.IsFiltered() {
|
|
return m, m.list.NewStatusMessage("Clear fuzzy filter before section jumps.")
|
|
}
|
|
m.jumpSection(-1)
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if !filtering && len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
|
|
if m.list.IsFiltered() {
|
|
return m, m.list.NewStatusMessage("Clear fuzzy filter before section jumps.")
|
|
}
|
|
m.jumpToSection(int(key[0] - '1'))
|
|
return m, nil
|
|
}
|
|
|
|
if m.focus == tuiFocusDetail && !filtering {
|
|
var cmd tea.Cmd
|
|
m.detail, cmd = m.detail.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.list, cmd = m.list.Update(msg)
|
|
m.refreshDetail(false)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m dealsTUIModel) View() string {
|
|
if m.loading {
|
|
return m.loadingView()
|
|
}
|
|
if m.width == 0 || m.height == 0 {
|
|
return tuiMetaStyle.Render("Loading interface...")
|
|
}
|
|
if m.tooSmall {
|
|
return lipgloss.NewStyle().
|
|
Padding(1, 2).
|
|
Render(
|
|
fmt.Sprintf(
|
|
"Terminal too small (%dx%d).\nResize to at least %dx%d for the two-pane deal explorer.",
|
|
m.width, m.height, minTUIWidth, minTUIHeight,
|
|
),
|
|
)
|
|
}
|
|
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
m.headerView(),
|
|
m.bodyView(),
|
|
m.footerView(),
|
|
)
|
|
}
|
|
|
|
func (m dealsTUIModel) loadingView() string {
|
|
width := m.width
|
|
if width == 0 {
|
|
width = 80
|
|
}
|
|
skeletonStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("240"))
|
|
|
|
lines := []string{
|
|
tuiHeaderStyle.Render("pubcli tui"),
|
|
tuiMetaStyle.Render("Preparing interactive interface..."),
|
|
"",
|
|
fmt.Sprintf("%s Fetching store and weekly deals", m.spinner.View()),
|
|
tuiHintStyle.Render("Tip: press q to cancel."),
|
|
"",
|
|
skeletonStyle.Render("┌──────────────────────────────┬─────────────────────────────────────────┐"),
|
|
skeletonStyle.Render("│ Loading deal list... │ Loading detail panel... │"),
|
|
skeletonStyle.Render("│ • categories │ • pricing and validity metadata │"),
|
|
skeletonStyle.Render("│ • sections │ • wrapped description text │"),
|
|
skeletonStyle.Render("│ • filter index │ • scroll viewport │"),
|
|
skeletonStyle.Render("└──────────────────────────────┴─────────────────────────────────────────┘"),
|
|
}
|
|
|
|
return lipgloss.NewStyle().
|
|
Width(width).
|
|
Padding(1, 2).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (m *dealsTUIModel) resize() {
|
|
if m.width == 0 || m.height == 0 {
|
|
return
|
|
}
|
|
if m.loading {
|
|
return
|
|
}
|
|
|
|
m.tooSmall = m.width < minTUIWidth || m.height < minTUIHeight
|
|
if m.tooSmall {
|
|
return
|
|
}
|
|
|
|
headerH := 3
|
|
footerH := 2
|
|
if m.showHelp {
|
|
footerH = 7
|
|
}
|
|
m.bodyHeight = maxInt(8, m.height-headerH-footerH-1)
|
|
|
|
listWidth := maxInt(40, int(float64(m.width)*0.43))
|
|
if listWidth > m.width-42 {
|
|
listWidth = m.width / 2
|
|
}
|
|
detailWidth := m.width - listWidth - 1
|
|
if detailWidth < 36 {
|
|
detailWidth = 36
|
|
listWidth = m.width - detailWidth - 1
|
|
}
|
|
|
|
m.listPaneWidth = listWidth
|
|
m.detailPaneWidth = detailWidth
|
|
|
|
listInnerWidth := maxInt(24, listWidth-4)
|
|
detailInnerWidth := maxInt(24, detailWidth-4)
|
|
panelInnerHeight := maxInt(6, m.bodyHeight-2)
|
|
|
|
m.list.SetSize(listInnerWidth, panelInnerHeight)
|
|
m.detail.Width = detailInnerWidth
|
|
m.detail.Height = panelInnerHeight
|
|
m.refreshDetail(false)
|
|
}
|
|
|
|
func (m dealsTUIModel) headerView() string {
|
|
focus := "list"
|
|
if m.focus == tuiFocusDetail {
|
|
focus = "detail"
|
|
}
|
|
|
|
top := fmt.Sprintf("pubcli tui | %s", m.storeLabel)
|
|
bottom := fmt.Sprintf(
|
|
"deals: %d visible / %d total | filters: %s | focus: %s",
|
|
m.visibleDeals, len(m.allDeals), m.activeFilterSummary(), focus,
|
|
)
|
|
|
|
return lipgloss.NewStyle().
|
|
Width(m.width).
|
|
Padding(0, 1).
|
|
Render(tuiHeaderStyle.Render(top) + "\n" + tuiMetaStyle.Render(bottom))
|
|
}
|
|
|
|
func (m dealsTUIModel) bodyView() string {
|
|
listBorder := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("241")).
|
|
Padding(0, 1)
|
|
detailBorder := listBorder
|
|
|
|
if m.focus == tuiFocusList {
|
|
listBorder = listBorder.BorderForeground(lipgloss.Color("86"))
|
|
} else {
|
|
detailBorder = detailBorder.BorderForeground(lipgloss.Color("86"))
|
|
}
|
|
|
|
left := listBorder.
|
|
Width(m.listPaneWidth).
|
|
Height(m.bodyHeight).
|
|
Render(m.list.View())
|
|
right := detailBorder.
|
|
Width(m.detailPaneWidth).
|
|
Height(m.bodyHeight).
|
|
Render(m.detail.View())
|
|
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
|
}
|
|
|
|
func (m dealsTUIModel) footerView() string {
|
|
base := "Tab switch pane • / fuzzy filter • s sort • g bogo • c category • a department • l limit • r reset • [/] section jump • 1-9 section index • q quit"
|
|
if m.focus == tuiFocusDetail {
|
|
base = "Detail: j/k or ↑/↓ scroll • u/d half-page • b/f page • esc list • ? help • q quit"
|
|
}
|
|
|
|
if !m.showHelp {
|
|
return lipgloss.NewStyle().Padding(0, 1).Render(tuiHintStyle.Render(base))
|
|
}
|
|
|
|
lines := []string{
|
|
"Key Help",
|
|
"list pane: ↑/↓ or j/k move • / fuzzy filter • c category • a department • g bogo • s sort • l limit",
|
|
"group jumps: ] next section • [ previous section • 1..9 jump to numbered section header",
|
|
"detail pane: j/k or ↑/↓ scroll • u/d half-page • b/f page up/down",
|
|
"global: tab switch pane • esc list • r reset inline options • ? toggle help • q quit • ctrl+c force quit",
|
|
}
|
|
return lipgloss.NewStyle().
|
|
Padding(0, 1).
|
|
Render(tuiHintStyle.Render(strings.Join(lines, "\n")))
|
|
}
|
|
|
|
func (m *dealsTUIModel) initializeInlineChoices() {
|
|
m.opts = canonicalizeTUIOptions(m.opts)
|
|
|
|
m.sortChoices = []string{"", "savings", "ending"}
|
|
m.categoryChoices = buildCategoryChoices(m.allDeals, m.opts.Category)
|
|
m.departmentChoices = buildDepartmentChoices(m.allDeals, m.opts.Department)
|
|
m.limitChoices = buildLimitChoices(m.opts.Limit)
|
|
|
|
m.syncChoiceIndexesFromOptions()
|
|
}
|
|
|
|
func (m *dealsTUIModel) syncChoiceIndexesFromOptions() {
|
|
m.sortIndex = indexOfString(m.sortChoices, canonicalSortMode(m.opts.Sort))
|
|
if m.sortIndex < 0 {
|
|
m.sortIndex = 0
|
|
}
|
|
m.opts.Sort = m.sortChoices[m.sortIndex]
|
|
|
|
m.categoryIndex = indexOfStringFold(m.categoryChoices, m.opts.Category)
|
|
if m.categoryIndex < 0 {
|
|
m.categoryIndex = 0
|
|
m.opts.Category = ""
|
|
} else {
|
|
m.opts.Category = m.categoryChoices[m.categoryIndex]
|
|
}
|
|
|
|
m.departmentIndex = indexOfStringFold(m.departmentChoices, m.opts.Department)
|
|
if m.departmentIndex < 0 {
|
|
m.departmentIndex = 0
|
|
m.opts.Department = ""
|
|
} else {
|
|
m.opts.Department = m.departmentChoices[m.departmentIndex]
|
|
}
|
|
|
|
m.limitIndex = indexOfInt(m.limitChoices, m.opts.Limit)
|
|
if m.limitIndex < 0 {
|
|
m.limitIndex = 0
|
|
m.opts.Limit = m.limitChoices[m.limitIndex]
|
|
}
|
|
}
|
|
|
|
func (m *dealsTUIModel) cycleSortMode() {
|
|
if len(m.sortChoices) == 0 {
|
|
return
|
|
}
|
|
m.sortIndex = (m.sortIndex + 1) % len(m.sortChoices)
|
|
m.opts.Sort = m.sortChoices[m.sortIndex]
|
|
m.applyCurrentFilters(false)
|
|
}
|
|
|
|
func (m *dealsTUIModel) cycleCategory() {
|
|
if len(m.categoryChoices) == 0 {
|
|
return
|
|
}
|
|
m.categoryIndex = (m.categoryIndex + 1) % len(m.categoryChoices)
|
|
m.opts.Category = m.categoryChoices[m.categoryIndex]
|
|
m.applyCurrentFilters(false)
|
|
}
|
|
|
|
func (m *dealsTUIModel) cycleDepartment() {
|
|
if len(m.departmentChoices) == 0 {
|
|
return
|
|
}
|
|
m.departmentIndex = (m.departmentIndex + 1) % len(m.departmentChoices)
|
|
m.opts.Department = m.departmentChoices[m.departmentIndex]
|
|
m.applyCurrentFilters(false)
|
|
}
|
|
|
|
func (m *dealsTUIModel) cycleLimit() {
|
|
if len(m.limitChoices) == 0 {
|
|
return
|
|
}
|
|
m.limitIndex = (m.limitIndex + 1) % len(m.limitChoices)
|
|
m.opts.Limit = m.limitChoices[m.limitIndex]
|
|
m.applyCurrentFilters(false)
|
|
}
|
|
|
|
func (m dealsTUIModel) activeFilterSummary() string {
|
|
parts := []string{}
|
|
if m.opts.BOGO {
|
|
parts = append(parts, "bogo")
|
|
}
|
|
if m.opts.Category != "" {
|
|
parts = append(parts, "category:"+m.opts.Category)
|
|
}
|
|
if m.opts.Department != "" {
|
|
parts = append(parts, "department:"+m.opts.Department)
|
|
}
|
|
if m.opts.Query != "" {
|
|
parts = append(parts, "query:"+m.opts.Query)
|
|
}
|
|
if m.opts.Sort != "" {
|
|
parts = append(parts, "sort:"+m.opts.Sort)
|
|
}
|
|
if m.opts.Limit > 0 {
|
|
parts = append(parts, fmt.Sprintf("limit:%d", m.opts.Limit))
|
|
}
|
|
if fuzzy := strings.TrimSpace(m.list.FilterValue()); fuzzy != "" {
|
|
parts = append(parts, "fuzzy:"+fuzzy)
|
|
}
|
|
if len(parts) == 0 {
|
|
return "none"
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (m *dealsTUIModel) applyCurrentFilters(resetSelection bool) {
|
|
currentID := m.selectedID
|
|
filtered := filter.Apply(m.allDeals, m.opts)
|
|
m.visibleDeals = len(filtered)
|
|
|
|
items, starts := buildGroupedListItems(filtered)
|
|
m.groupStarts = starts
|
|
|
|
m.list.Title = fmt.Sprintf("Deals • %d visible", m.visibleDeals)
|
|
m.list.SetItems(items)
|
|
|
|
target := -1
|
|
if !resetSelection && currentID != "" {
|
|
target = findItemIndexByID(items, currentID)
|
|
}
|
|
if target < 0 {
|
|
target = firstDealItemIndex(items)
|
|
}
|
|
if target < 0 && len(items) > 0 {
|
|
target = 0
|
|
}
|
|
if target >= 0 {
|
|
m.list.Select(target)
|
|
}
|
|
|
|
m.refreshDetail(true)
|
|
}
|
|
|
|
func (m *dealsTUIModel) refreshDetail(resetScroll bool) {
|
|
var content string
|
|
nextID := ""
|
|
|
|
if selected := m.list.SelectedItem(); selected != nil {
|
|
switch item := selected.(type) {
|
|
case tuiDealItem:
|
|
content = renderDealDetailContent(item.deal, m.detail.Width)
|
|
nextID = stableIDForDeal(item.deal, item.title)
|
|
case tuiGroupItem:
|
|
content = m.renderGroupDetail(item)
|
|
nextID = stableIDForGroup(item.name)
|
|
}
|
|
}
|
|
if content == "" {
|
|
content = "No deals match the current inline filters.\n\nTry pressing r to reset filters."
|
|
}
|
|
|
|
if resetScroll || nextID != m.selectedID {
|
|
m.detail.GotoTop()
|
|
}
|
|
m.selectedID = nextID
|
|
m.detail.SetContent(content)
|
|
}
|
|
|
|
func (m dealsTUIModel) renderGroupDetail(group tuiGroupItem) string {
|
|
preview := m.groupPreviewTitles(group.name, 5)
|
|
|
|
lines := []string{
|
|
tuiSectionStyle.Render(fmt.Sprintf("Section %d: %s", group.ordinal, group.name)),
|
|
tuiMetaStyle.Render(fmt.Sprintf("%d deals in this section", group.count)),
|
|
"",
|
|
tuiMetaStyle.Render("Jump keys:"),
|
|
"- `]` next section, `[` previous section",
|
|
"- `1..9` jump directly to section number",
|
|
}
|
|
if len(preview) > 0 {
|
|
lines = append(lines, "")
|
|
lines = append(lines, tuiMetaStyle.Render("Preview:"))
|
|
for _, title := range preview {
|
|
lines = append(lines, "• "+title)
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m dealsTUIModel) groupPreviewTitles(group string, max int) []string {
|
|
out := make([]string, 0, max)
|
|
for _, item := range m.list.Items() {
|
|
deal, ok := item.(tuiDealItem)
|
|
if !ok || deal.group != group {
|
|
continue
|
|
}
|
|
out = append(out, deal.title)
|
|
if len(out) >= max {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *dealsTUIModel) jumpToSection(index int) {
|
|
if index < 0 || index >= len(m.groupStarts) {
|
|
return
|
|
}
|
|
|
|
target := firstDealIndexFrom(m.list.Items(), m.groupStarts[index])
|
|
if target < 0 {
|
|
target = m.groupStarts[index]
|
|
}
|
|
m.list.Select(target)
|
|
m.refreshDetail(true)
|
|
}
|
|
|
|
func (m *dealsTUIModel) jumpSection(delta int) {
|
|
if len(m.groupStarts) == 0 {
|
|
return
|
|
}
|
|
|
|
current := m.currentSectionIndex()
|
|
if current < 0 {
|
|
current = 0
|
|
}
|
|
next := current + delta
|
|
if next < 0 {
|
|
next = len(m.groupStarts) - 1
|
|
}
|
|
if next >= len(m.groupStarts) {
|
|
next = 0
|
|
}
|
|
m.jumpToSection(next)
|
|
}
|
|
|
|
func (m dealsTUIModel) currentSectionIndex() int {
|
|
if len(m.groupStarts) == 0 {
|
|
return -1
|
|
}
|
|
cursor := m.list.GlobalIndex()
|
|
current := 0
|
|
for i, start := range m.groupStarts {
|
|
if start <= cursor {
|
|
current = i
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return current
|
|
}
|
|
|
|
func buildGroupedListItems(deals []api.SavingItem) (items []list.Item, starts []int) {
|
|
if len(deals) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
groups := map[string][]api.SavingItem{}
|
|
for _, deal := range deals {
|
|
group := dealGroupLabel(deal)
|
|
groups[group] = append(groups[group], deal)
|
|
}
|
|
|
|
type groupMeta struct {
|
|
name string
|
|
count int
|
|
}
|
|
|
|
metas := make([]groupMeta, 0, len(groups))
|
|
for name, deals := range groups {
|
|
metas = append(metas, groupMeta{name: name, count: len(deals)})
|
|
}
|
|
sort.Slice(metas, func(i, j int) bool {
|
|
if metas[i].name == "BOGO" && metas[j].name != "BOGO" {
|
|
return true
|
|
}
|
|
if metas[j].name == "BOGO" && metas[i].name != "BOGO" {
|
|
return false
|
|
}
|
|
if metas[i].count != metas[j].count {
|
|
return metas[i].count > metas[j].count
|
|
}
|
|
return metas[i].name < metas[j].name
|
|
})
|
|
|
|
items = make([]list.Item, 0, len(deals)+len(metas))
|
|
starts = make([]int, 0, len(metas))
|
|
for idx, meta := range metas {
|
|
starts = append(starts, len(items))
|
|
|
|
items = append(items, tuiGroupItem{
|
|
name: meta.name,
|
|
count: meta.count,
|
|
ordinal: idx + 1,
|
|
})
|
|
for _, deal := range groups[meta.name] {
|
|
items = append(items, buildTUIDealItem(deal, meta.name))
|
|
}
|
|
}
|
|
|
|
return items, starts
|
|
}
|
|
|
|
func dealGroupLabel(item api.SavingItem) string {
|
|
if filter.ContainsIgnoreCase(item.Categories, "bogo") {
|
|
return "BOGO"
|
|
}
|
|
for _, category := range item.Categories {
|
|
clean := strings.TrimSpace(category)
|
|
if clean == "" || strings.EqualFold(clean, "bogo") {
|
|
continue
|
|
}
|
|
return humanizeLabel(clean)
|
|
}
|
|
if dept := strings.TrimSpace(filter.CleanText(filter.Deref(item.Department))); dept != "" {
|
|
return humanizeLabel(dept)
|
|
}
|
|
return "Other"
|
|
}
|
|
|
|
func buildTUIDealItem(item api.SavingItem, group string) tuiDealItem {
|
|
title := topDealTitle(item)
|
|
savings := filter.CleanText(filter.Deref(item.Savings))
|
|
if savings == "" {
|
|
savings = "No savings text"
|
|
}
|
|
dept := filter.CleanText(filter.Deref(item.Department))
|
|
end := strings.TrimSpace(item.EndFormatted)
|
|
|
|
descParts := []string{savings}
|
|
if dept != "" {
|
|
descParts = append(descParts, dept)
|
|
}
|
|
if end != "" {
|
|
descParts = append(descParts, "ends "+end)
|
|
}
|
|
|
|
filterTokens := []string{
|
|
title,
|
|
savings,
|
|
filter.CleanText(filter.Deref(item.Description)),
|
|
filter.CleanText(filter.Deref(item.Brand)),
|
|
strings.Join(item.Categories, " "),
|
|
dept,
|
|
end,
|
|
group,
|
|
}
|
|
|
|
return tuiDealItem{
|
|
deal: item,
|
|
group: group,
|
|
title: title,
|
|
description: strings.Join(descParts, " • "),
|
|
filterValue: strings.ToLower(strings.Join(filterTokens, " ")),
|
|
}
|
|
}
|
|
|
|
func renderDealDetailContent(item api.SavingItem, width int) string {
|
|
maxWidth := maxInt(24, width)
|
|
|
|
title := topDealTitle(item)
|
|
savings := filter.CleanText(filter.Deref(item.Savings))
|
|
if savings == "" {
|
|
savings = "No savings value provided"
|
|
}
|
|
|
|
desc := filter.CleanText(filter.Deref(item.Description))
|
|
if desc == "" {
|
|
desc = "No description provided."
|
|
}
|
|
|
|
dept := filter.CleanText(filter.Deref(item.Department))
|
|
brand := filter.CleanText(filter.Deref(item.Brand))
|
|
dealInfo := filter.CleanText(filter.Deref(item.AdditionalDealInfo))
|
|
validity := strings.TrimSpace(item.StartFormatted + " - " + item.EndFormatted)
|
|
imageURL := strings.TrimSpace(filter.Deref(item.ImageURL))
|
|
|
|
lines := []string{
|
|
tuiDealStyle.Render(wrapText(title, maxWidth)),
|
|
}
|
|
|
|
metaBits := []string{}
|
|
if filter.ContainsIgnoreCase(item.Categories, "bogo") {
|
|
metaBits = append(metaBits, tuiBogoStyle.Render("BOGO"))
|
|
}
|
|
if len(item.Categories) > 0 {
|
|
metaBits = append(metaBits, "categories: "+strings.Join(item.Categories, ", "))
|
|
}
|
|
if len(metaBits) > 0 {
|
|
lines = append(lines, tuiMetaStyle.Render(wrapText(strings.Join(metaBits, " | "), maxWidth)))
|
|
}
|
|
|
|
lines = append(lines, "")
|
|
lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Savings:"), tuiValueStyle.Render(savings)))
|
|
if dealInfo != "" {
|
|
lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Deal info:"), wrapText(dealInfo, maxWidth)))
|
|
}
|
|
lines = append(lines, "")
|
|
lines = append(lines, tuiMetaStyle.Render("Description:"))
|
|
lines = append(lines, wrapText(desc, maxWidth))
|
|
lines = append(lines, "")
|
|
|
|
if dept != "" {
|
|
lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Department:"), dept))
|
|
}
|
|
if brand != "" {
|
|
lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Brand:"), brand))
|
|
}
|
|
if strings.Trim(validity, " -") != "" {
|
|
lines = append(lines, fmt.Sprintf("%s %s", tuiMetaStyle.Render("Valid:"), strings.Trim(validity, " -")))
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s %.2f", tuiMetaStyle.Render("Score:"), filter.DealScore(item)))
|
|
|
|
if imageURL != "" {
|
|
lines = append(lines, "")
|
|
lines = append(lines, tuiMutedStyle.Render("Image URL:"))
|
|
lines = append(lines, tuiMutedStyle.Render(wrapText(imageURL, maxWidth)))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func wrapText(text string, width int) string {
|
|
words := strings.Fields(text)
|
|
if len(words) == 0 {
|
|
return ""
|
|
}
|
|
if width < 12 {
|
|
width = 12
|
|
}
|
|
|
|
line := words[0]
|
|
lines := make([]string, 0, len(words)/6+1)
|
|
for _, w := range words[1:] {
|
|
if len(line)+1+len(w) > width {
|
|
lines = append(lines, line)
|
|
line = w
|
|
continue
|
|
}
|
|
line += " " + w
|
|
}
|
|
lines = append(lines, line)
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func canonicalizeTUIOptions(opts filter.Options) filter.Options {
|
|
opts.Sort = canonicalSortMode(opts.Sort)
|
|
if opts.Category != "" {
|
|
opts.Category = strings.TrimSpace(opts.Category)
|
|
}
|
|
if opts.Department != "" {
|
|
opts.Department = strings.TrimSpace(opts.Department)
|
|
}
|
|
if opts.Query != "" {
|
|
opts.Query = strings.TrimSpace(opts.Query)
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func canonicalSortMode(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "savings":
|
|
return "savings"
|
|
case "ending", "end", "expiry", "expiration":
|
|
return "ending"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func buildCategoryChoices(items []api.SavingItem, current string) []string {
|
|
type bucket struct {
|
|
label string
|
|
count int
|
|
}
|
|
counts := map[string]bucket{}
|
|
for _, item := range items {
|
|
for _, category := range item.Categories {
|
|
clean := strings.ToLower(strings.TrimSpace(category))
|
|
if clean == "" {
|
|
continue
|
|
}
|
|
entry := counts[clean]
|
|
entry.label = clean
|
|
entry.count++
|
|
counts[clean] = entry
|
|
}
|
|
}
|
|
|
|
values := make([]string, 0, len(counts))
|
|
for _, value := range counts {
|
|
values = append(values, value.label)
|
|
}
|
|
if current != "" && indexOfStringFold(values, current) < 0 {
|
|
values = append(values, current)
|
|
}
|
|
sort.Strings(values)
|
|
sort.SliceStable(values, func(i, j int) bool {
|
|
left := counts[strings.ToLower(values[i])].count
|
|
right := counts[strings.ToLower(values[j])].count
|
|
if left != right {
|
|
return left > right
|
|
}
|
|
return strings.ToLower(values[i]) < strings.ToLower(values[j])
|
|
})
|
|
return append([]string{""}, values...)
|
|
}
|
|
|
|
func buildDepartmentChoices(items []api.SavingItem, current string) []string {
|
|
type bucket struct {
|
|
label string
|
|
count int
|
|
}
|
|
counts := map[string]bucket{}
|
|
for _, item := range items {
|
|
dept := strings.ToLower(strings.TrimSpace(filter.CleanText(filter.Deref(item.Department))))
|
|
if dept == "" {
|
|
continue
|
|
}
|
|
entry := counts[dept]
|
|
entry.label = dept
|
|
entry.count++
|
|
counts[dept] = entry
|
|
}
|
|
|
|
values := make([]string, 0, len(counts))
|
|
for _, value := range counts {
|
|
values = append(values, value.label)
|
|
}
|
|
if current != "" && indexOfStringFold(values, current) < 0 {
|
|
values = append(values, current)
|
|
}
|
|
sort.Strings(values)
|
|
sort.SliceStable(values, func(i, j int) bool {
|
|
left := counts[strings.ToLower(values[i])].count
|
|
right := counts[strings.ToLower(values[j])].count
|
|
if left != right {
|
|
return left > right
|
|
}
|
|
return strings.ToLower(values[i]) < strings.ToLower(values[j])
|
|
})
|
|
return append([]string{""}, values...)
|
|
}
|
|
|
|
func buildLimitChoices(current int) []int {
|
|
values := []int{0, 10, 25, 50, 100}
|
|
if current > 0 && indexOfInt(values, current) < 0 {
|
|
values = append(values, current)
|
|
sort.Ints(values)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func indexOfString(values []string, target string) int {
|
|
for i, value := range values {
|
|
if value == target {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func indexOfStringFold(values []string, target string) int {
|
|
for i, value := range values {
|
|
if strings.EqualFold(value, target) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func indexOfInt(values []int, target int) int {
|
|
for i, value := range values {
|
|
if value == target {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func findItemIndexByID(items []list.Item, stableID string) int {
|
|
for i, item := range items {
|
|
if stableIDForItem(item) == stableID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func firstDealItemIndex(items []list.Item) int {
|
|
return firstDealIndexFrom(items, 0)
|
|
}
|
|
|
|
func firstDealIndexFrom(items []list.Item, start int) int {
|
|
for i := start; i < len(items); i++ {
|
|
if _, ok := items[i].(tuiDealItem); ok {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func stableIDForItem(item list.Item) string {
|
|
switch value := item.(type) {
|
|
case tuiDealItem:
|
|
return stableIDForDeal(value.deal, value.title)
|
|
case tuiGroupItem:
|
|
return stableIDForGroup(value.name)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func stableIDForDeal(item api.SavingItem, fallbackTitle string) string {
|
|
if id := strings.TrimSpace(item.ID); id != "" {
|
|
return "deal:" + id
|
|
}
|
|
if fallbackTitle != "" {
|
|
return "deal:title:" + strings.ToLower(strings.TrimSpace(fallbackTitle))
|
|
}
|
|
return "deal:unknown"
|
|
}
|
|
|
|
func stableIDForGroup(group string) string {
|
|
return "group:" + strings.ToLower(strings.TrimSpace(group))
|
|
}
|
|
|
|
func humanizeLabel(raw string) string {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" {
|
|
return "Other"
|
|
}
|
|
s = strings.ReplaceAll(s, "_", " ")
|
|
s = strings.ReplaceAll(s, "-", " ")
|
|
words := strings.Fields(strings.ToLower(s))
|
|
for i, word := range words {
|
|
if len(word) == 0 {
|
|
continue
|
|
}
|
|
words[i] = strings.ToUpper(word[:1]) + word[1:]
|
|
}
|
|
return strings.Join(words, " ")
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|