Replace the simple nil-title → "Unknown" logic in printDeal with a fallbackDealTitle() function that tries multiple fields in priority order before giving up: 1. Title (cleaned) — the happy path, same as before 2. Brand + Department — e.g. "Publix deal (Meat)" 3. Brand alone — e.g. "Publix deal" 4. Department alone — e.g. "Meat deal" 5. Description truncated to 48 chars — last-resort meaningful text 6. Item ID — e.g. "Deal 12345" 7. "Untitled deal" — only when every field is empty This makes the output more useful for the ~5-10% of weekly ad items that ship with a nil Title from the Publix API, which previously all showed as "Unknown" and were indistinguishable from each other. Tests: - TestPrintDeals_FallbackTitleFromBrandAndDepartment - TestPrintDeals_FallbackTitleFromID Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
268 lines
7.6 KiB
Go
268 lines
7.6 KiB
Go
package display
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/tayloree/publix-deals/internal/api"
|
|
"github.com/tayloree/publix-deals/internal/filter"
|
|
)
|
|
|
|
// Styles for terminal output.
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true)
|
|
bogoTag = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("5")) // magenta
|
|
priceStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
|
|
dealStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
|
|
dimStyle = lipgloss.NewStyle().Faint(true)
|
|
cyanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
|
|
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2"))
|
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
|
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
|
|
)
|
|
|
|
// DealJSON is the JSON output shape for a deal.
|
|
type DealJSON struct {
|
|
Title string `json:"title"`
|
|
Savings string `json:"savings"`
|
|
Description string `json:"description"`
|
|
Department string `json:"department"`
|
|
Categories []string `json:"categories"`
|
|
DealInfo string `json:"additionalDealInfo"`
|
|
Brand string `json:"brand"`
|
|
ValidFrom string `json:"validFrom"`
|
|
ValidTo string `json:"validTo"`
|
|
IsBogo bool `json:"isBogo"`
|
|
ImageURL string `json:"imageUrl"`
|
|
}
|
|
|
|
// StoreJSON is the JSON output shape for a store.
|
|
type StoreJSON struct {
|
|
Number string `json:"number"`
|
|
Name string `json:"name"`
|
|
Address string `json:"address"`
|
|
Distance string `json:"distance"`
|
|
}
|
|
|
|
// PrintDeals renders a list of deals to the writer.
|
|
func PrintDeals(w io.Writer, items []api.SavingItem) {
|
|
dateRange := ""
|
|
if len(items) > 0 && items[0].StartFormatted != "" {
|
|
dateRange = fmt.Sprintf(" (%s - %s)", items[0].StartFormatted, items[0].EndFormatted)
|
|
}
|
|
|
|
fmt.Fprintf(w, "\n%s%s — %s\n\n",
|
|
headerStyle.Render("Publix Weekly Deals"),
|
|
dateRange,
|
|
cyanStyle.Render(fmt.Sprintf("%d items", len(items))),
|
|
)
|
|
|
|
for _, item := range items {
|
|
printDeal(w, item)
|
|
fmt.Fprintln(w)
|
|
}
|
|
}
|
|
|
|
// PrintDealsJSON renders deals as JSON.
|
|
func PrintDealsJSON(w io.Writer, items []api.SavingItem) error {
|
|
out := make([]DealJSON, 0, len(items))
|
|
for _, item := range items {
|
|
out = append(out, toDealJSON(item))
|
|
}
|
|
return json.NewEncoder(w).Encode(out)
|
|
}
|
|
|
|
// PrintStores renders a list of stores to the writer.
|
|
func PrintStores(w io.Writer, stores []api.Store, zipCode string) {
|
|
fmt.Fprintf(w, "\n%s\n\n",
|
|
titleStyle.Render(fmt.Sprintf("Publix stores near %s:", zipCode)),
|
|
)
|
|
for _, s := range stores {
|
|
num := api.StoreNumber(s.Key)
|
|
fmt.Fprintf(w, " %s %s\n", cyanStyle.Render("#"+num), titleStyle.Render(s.Name))
|
|
fmt.Fprintf(w, " %s, %s, %s %s\n", s.Addr, s.City, s.State, s.Zip)
|
|
if s.Distance != "" {
|
|
fmt.Fprintf(w, " %s\n", dimStyle.Render(s.Distance+" miles"))
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
}
|
|
|
|
// PrintStoresJSON renders stores as JSON.
|
|
func PrintStoresJSON(w io.Writer, stores []api.Store) error {
|
|
out := make([]StoreJSON, 0, len(stores))
|
|
for _, s := range stores {
|
|
out = append(out, StoreJSON{
|
|
Number: api.StoreNumber(s.Key),
|
|
Name: s.Name,
|
|
Address: fmt.Sprintf("%s, %s, %s %s", s.Addr, s.City, s.State, s.Zip),
|
|
Distance: s.Distance,
|
|
})
|
|
}
|
|
return json.NewEncoder(w).Encode(out)
|
|
}
|
|
|
|
// PrintCategories renders a list of categories and their counts.
|
|
func PrintCategories(w io.Writer, cats map[string]int, storeNumber string) {
|
|
type catCount struct {
|
|
Name string
|
|
Count int
|
|
}
|
|
sorted := make([]catCount, 0, len(cats))
|
|
for k, v := range cats {
|
|
sorted = append(sorted, catCount{k, v})
|
|
}
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Count > sorted[j].Count })
|
|
|
|
fmt.Fprintf(w, "\n%s\n\n",
|
|
titleStyle.Render(fmt.Sprintf("Categories for store #%s this week:", storeNumber)),
|
|
)
|
|
for _, c := range sorted {
|
|
fmt.Fprintf(w, " %s: %d deals\n", cyanStyle.Render(c.Name), c.Count)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// PrintCategoriesJSON renders categories as JSON.
|
|
func PrintCategoriesJSON(w io.Writer, cats map[string]int) error {
|
|
return json.NewEncoder(w).Encode(cats)
|
|
}
|
|
|
|
// PrintStoreContext prints a dim line showing which store was auto-selected.
|
|
func PrintStoreContext(w io.Writer, store api.Store) {
|
|
num := api.StoreNumber(store.Key)
|
|
fmt.Fprintf(w, "%s\n\n",
|
|
dimStyle.Render(fmt.Sprintf("Using store: #%s — %s (%s, %s)", num, store.Name, store.City, store.State)),
|
|
)
|
|
}
|
|
|
|
// PrintError prints a styled error message.
|
|
func PrintError(w io.Writer, msg string) {
|
|
fmt.Fprintln(w, errorStyle.Render(msg))
|
|
}
|
|
|
|
// PrintWarning prints a styled warning message.
|
|
func PrintWarning(w io.Writer, msg string) {
|
|
fmt.Fprintln(w, warningStyle.Render(msg))
|
|
}
|
|
|
|
func printDeal(w io.Writer, item api.SavingItem) {
|
|
title := fallbackDealTitle(item)
|
|
savings := filter.CleanText(filter.Deref(item.Savings))
|
|
desc := filter.CleanText(filter.Deref(item.Description))
|
|
dept := filter.CleanText(filter.Deref(item.Department))
|
|
dealInfo := filter.CleanText(filter.Deref(item.AdditionalDealInfo))
|
|
isBogo := filter.ContainsIgnoreCase(item.Categories, "bogo")
|
|
|
|
// Title line
|
|
tag := ""
|
|
if isBogo {
|
|
tag = bogoTag.Render("BOGO") + " "
|
|
}
|
|
fmt.Fprintf(w, " %s%s\n", tag, titleStyle.Render(title))
|
|
|
|
// Price / savings
|
|
var parts []string
|
|
if savings != "" {
|
|
parts = append(parts, priceStyle.Render(savings))
|
|
}
|
|
if dealInfo != "" {
|
|
parts = append(parts, dealStyle.Render(dealInfo))
|
|
}
|
|
if len(parts) > 0 {
|
|
fmt.Fprintf(w, " %s\n", strings.Join(parts, " | "))
|
|
}
|
|
|
|
// Description
|
|
if desc != "" {
|
|
fmt.Fprintf(w, " %s\n", dimStyle.Render(wordWrap(desc, 72, " ")))
|
|
}
|
|
|
|
// Meta
|
|
var meta []string
|
|
if item.StartFormatted != "" && item.EndFormatted != "" {
|
|
meta = append(meta, fmt.Sprintf("Valid %s - %s", item.StartFormatted, item.EndFormatted))
|
|
}
|
|
if dept != "" {
|
|
meta = append(meta, dept)
|
|
}
|
|
if len(meta) > 0 {
|
|
fmt.Fprintf(w, " %s\n", dimStyle.Render(strings.Join(meta, " | ")))
|
|
}
|
|
}
|
|
|
|
func fallbackDealTitle(item api.SavingItem) string {
|
|
if title := filter.CleanText(filter.Deref(item.Title)); title != "" {
|
|
return title
|
|
}
|
|
|
|
brand := filter.CleanText(filter.Deref(item.Brand))
|
|
dept := filter.CleanText(filter.Deref(item.Department))
|
|
switch {
|
|
case brand != "" && dept != "":
|
|
return fmt.Sprintf("%s deal (%s)", brand, dept)
|
|
case brand != "":
|
|
return brand + " deal"
|
|
case dept != "":
|
|
return dept + " deal"
|
|
}
|
|
|
|
if desc := filter.CleanText(filter.Deref(item.Description)); desc != "" {
|
|
const max = 48
|
|
if len(desc) > max {
|
|
return desc[:max-3] + "..."
|
|
}
|
|
return desc
|
|
}
|
|
|
|
if item.ID != "" {
|
|
return "Deal " + item.ID
|
|
}
|
|
|
|
return "Untitled deal"
|
|
}
|
|
|
|
func toDealJSON(item api.SavingItem) DealJSON {
|
|
categories := item.Categories
|
|
if categories == nil {
|
|
categories = []string{}
|
|
}
|
|
return DealJSON{
|
|
Title: filter.CleanText(filter.Deref(item.Title)),
|
|
Savings: filter.CleanText(filter.Deref(item.Savings)),
|
|
Description: filter.CleanText(filter.Deref(item.Description)),
|
|
Department: filter.CleanText(filter.Deref(item.Department)),
|
|
Categories: categories,
|
|
DealInfo: filter.CleanText(filter.Deref(item.AdditionalDealInfo)),
|
|
Brand: filter.CleanText(filter.Deref(item.Brand)),
|
|
ValidFrom: item.StartFormatted,
|
|
ValidTo: item.EndFormatted,
|
|
IsBogo: filter.ContainsIgnoreCase(item.Categories, "bogo"),
|
|
ImageURL: filter.Deref(item.ImageURL),
|
|
}
|
|
}
|
|
|
|
func wordWrap(text string, width int, indent string) string {
|
|
words := strings.Fields(text)
|
|
if len(words) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var lines []string
|
|
line := words[0]
|
|
for _, w := range words[1:] {
|
|
if len(line)+1+len(w) > width {
|
|
lines = append(lines, line)
|
|
line = w
|
|
} else {
|
|
line += " " + w
|
|
}
|
|
}
|
|
lines = append(lines, line)
|
|
return strings.Join(lines, "\n"+indent)
|
|
}
|