Files
pubcli/cmd/tui_model.go
teernisse da327fe759 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>
2026-02-23 01:32:10 -05:00

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
}