diff --git a/internal/display/formatter.go b/internal/display/formatter.go new file mode 100644 index 0000000..dd5afda --- /dev/null +++ b/internal/display/formatter.go @@ -0,0 +1,239 @@ +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 := filter.CleanText(filter.Deref(item.Title)) + if title == "" { + title = "Unknown" + } + 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 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) +} diff --git a/internal/display/formatter_test.go b/internal/display/formatter_test.go new file mode 100644 index 0000000..f0ab272 --- /dev/null +++ b/internal/display/formatter_test.go @@ -0,0 +1,152 @@ +package display_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tayloree/publix-deals/internal/api" + "github.com/tayloree/publix-deals/internal/display" +) + +func ptr(s string) *string { return &s } + +func sampleDeals() []api.SavingItem { + return []api.SavingItem{ + { + ID: "1", + Title: ptr("Chicken Breasts"), + Savings: ptr("$3.99 lb"), + Description: ptr("USDA Grade A, 97% Fat Free"), + Department: ptr("Meat"), + Brand: ptr("Publix"), + Categories: []string{"meat"}, + AdditionalDealInfo: ptr("SAVE UP TO $1.00 LB"), + StartFormatted: "2/18", + EndFormatted: "2/24", + }, + { + ID: "2", + Title: ptr("Nutella & More"), + Savings: ptr("Buy 1 Get 1 FREE"), + Department: ptr("Grocery"), + Categories: []string{"bogo", "grocery"}, + StartFormatted: "2/18", + EndFormatted: "2/24", + }, + } +} + +func TestPrintDeals_ContainsExpectedContent(t *testing.T) { + var buf bytes.Buffer + display.PrintDeals(&buf, sampleDeals()) + output := buf.String() + + assert.Contains(t, output, "Publix Weekly Deals") + assert.Contains(t, output, "2 items") + assert.Contains(t, output, "Chicken Breasts") + assert.Contains(t, output, "$3.99 lb") + assert.Contains(t, output, "SAVE UP TO $1.00 LB") + assert.Contains(t, output, "BOGO") // Nutella is bogo + // HTML entities should be unescaped + assert.Contains(t, output, "Nutella & More") + assert.NotContains(t, output, "&") +} + +func TestPrintDealsJSON(t *testing.T) { + var buf bytes.Buffer + err := display.PrintDealsJSON(&buf, sampleDeals()) + require.NoError(t, err) + assert.NotContains(t, buf.String(), "\n ") + + var deals []display.DealJSON + err = json.Unmarshal(buf.Bytes(), &deals) + require.NoError(t, err) + + assert.Len(t, deals, 2) + assert.Equal(t, "Chicken Breasts", deals[0].Title) + assert.Equal(t, "$3.99 lb", deals[0].Savings) + assert.Equal(t, "Meat", deals[0].Department) + assert.False(t, deals[0].IsBogo) + + // HTML entities should be clean in JSON too + assert.Equal(t, "Nutella & More", deals[1].Title) + assert.True(t, deals[1].IsBogo) +} + +func TestPrintDealsJSON_NilFields(t *testing.T) { + items := []api.SavingItem{{ID: "nil-test"}} + var buf bytes.Buffer + err := display.PrintDealsJSON(&buf, items) + require.NoError(t, err) + + var deals []display.DealJSON + err = json.Unmarshal(buf.Bytes(), &deals) + require.NoError(t, err) + assert.Len(t, deals, 1) + assert.Equal(t, "", deals[0].Title) + assert.NotNil(t, deals[0].Categories) +} + +func TestPrintStores(t *testing.T) { + stores := []api.Store{ + {Key: "01425", Name: "Peachers Mill", Addr: "1490 Tiny Town Rd", City: "Clarksville", State: "TN", Zip: "37042", Distance: "5"}, + } + var buf bytes.Buffer + display.PrintStores(&buf, stores, "37042") + output := buf.String() + + assert.Contains(t, output, "37042") + assert.Contains(t, output, "#1425") + assert.Contains(t, output, "Peachers Mill") + assert.Contains(t, output, "5 miles") +} + +func TestPrintStoresJSON(t *testing.T) { + stores := []api.Store{ + {Key: "01425", Name: "Peachers Mill", Addr: "1490 Tiny Town Rd", City: "Clarksville", State: "TN", Zip: "37042", Distance: "5"}, + } + var buf bytes.Buffer + err := display.PrintStoresJSON(&buf, stores) + require.NoError(t, err) + assert.NotContains(t, buf.String(), "\n ") + + var out []display.StoreJSON + err = json.Unmarshal(buf.Bytes(), &out) + require.NoError(t, err) + + assert.Len(t, out, 1) + assert.Equal(t, "1425", out[0].Number) + assert.Equal(t, "Peachers Mill", out[0].Name) + assert.Contains(t, out[0].Address, "Clarksville") +} + +func TestPrintCategories(t *testing.T) { + cats := map[string]int{"bogo": 10, "meat": 5, "produce": 3} + var buf bytes.Buffer + display.PrintCategories(&buf, cats, "1425") + output := buf.String() + + assert.Contains(t, output, "1425") + assert.Contains(t, output, "bogo") + assert.Contains(t, output, "10 deals") + assert.Contains(t, output, "meat") + assert.Contains(t, output, "produce") +} + +func TestPrintCategoriesJSON(t *testing.T) { + cats := map[string]int{"bogo": 10, "meat": 5} + var buf bytes.Buffer + err := display.PrintCategoriesJSON(&buf, cats) + require.NoError(t, err) + assert.NotContains(t, buf.String(), "\n ") + + var out map[string]int + err = json.Unmarshal(buf.Bytes(), &out) + require.NoError(t, err) + + assert.Equal(t, 10, out["bogo"]) + assert.Equal(t, 5, out["meat"]) +}