- add pubcli compare command to rank nearby stores by filtered deal coverage, bogo count, aggregate score, and distance tie-breaks - support --count (1-10) for comparison breadth and emit structured JSON/text output with ranked entries - add robust distance token parsing to tolerate upstream distance string formatting differences - add pubcli tui interactive terminal browser with paging, deal detail drill-in, and explicit TTY validation for stdin/stdout - share deal-filter flag registration across root/tui/compare and add --sort support in root execution path - validate sort mode early and allow canonical aliases (end, expiry, expiration) while preserving explicit invalid-arg guidance - expand tolerant CLI normalization for new commands/flags and aliases (orderby, sortby, count, bare-flag rewrite for compare/tui) - update quick-start flag list and integration tests to cover compare help and normalization behavior
199 lines
4.8 KiB
Go
199 lines
4.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/tayloree/publix-deals/internal/api"
|
|
"github.com/tayloree/publix-deals/internal/filter"
|
|
)
|
|
|
|
var flagCompareCount int
|
|
|
|
type compareStoreResult struct {
|
|
Rank int `json:"rank"`
|
|
Number string `json:"number"`
|
|
Name string `json:"name"`
|
|
City string `json:"city"`
|
|
State string `json:"state"`
|
|
Distance string `json:"distance"`
|
|
MatchedDeals int `json:"matchedDeals"`
|
|
BogoDeals int `json:"bogoDeals"`
|
|
Score float64 `json:"score"`
|
|
TopDeal string `json:"topDeal"`
|
|
}
|
|
|
|
var compareCmd = &cobra.Command{
|
|
Use: "compare",
|
|
Short: "Compare nearby stores by filtered deal quality",
|
|
Example: ` pubcli compare --zip 33101
|
|
pubcli compare --zip 33101 --category produce --sort savings
|
|
pubcli compare --zip 33101 --bogo --json`,
|
|
RunE: runCompare,
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(compareCmd)
|
|
|
|
registerDealFilterFlags(compareCmd.Flags())
|
|
compareCmd.Flags().IntVar(&flagCompareCount, "count", 5, "Number of nearby stores to compare (1-10)")
|
|
}
|
|
|
|
func runCompare(cmd *cobra.Command, _ []string) error {
|
|
if err := validateSortMode(); err != nil {
|
|
return err
|
|
}
|
|
if flagZip == "" {
|
|
return invalidArgsError(
|
|
"--zip is required for compare",
|
|
"pubcli compare --zip 33101",
|
|
"pubcli compare --zip 33101 --category produce",
|
|
)
|
|
}
|
|
if flagCompareCount < 1 || flagCompareCount > 10 {
|
|
return invalidArgsError(
|
|
"--count must be between 1 and 10",
|
|
"pubcli compare --zip 33101 --count 5",
|
|
)
|
|
}
|
|
|
|
client := api.NewClient()
|
|
stores, err := client.FetchStores(cmd.Context(), flagZip, flagCompareCount)
|
|
if err != nil {
|
|
return upstreamError("fetching stores", err)
|
|
}
|
|
if len(stores) == 0 {
|
|
return notFoundError(
|
|
fmt.Sprintf("no stores found near %s", flagZip),
|
|
"Try a nearby ZIP code.",
|
|
)
|
|
}
|
|
|
|
results := make([]compareStoreResult, 0, len(stores))
|
|
errCount := 0
|
|
for _, store := range stores {
|
|
storeNumber := api.StoreNumber(store.Key)
|
|
resp, fetchErr := client.FetchSavings(cmd.Context(), storeNumber)
|
|
if fetchErr != nil {
|
|
errCount++
|
|
continue
|
|
}
|
|
|
|
items := filter.Apply(resp.Savings, filter.Options{
|
|
BOGO: flagBogo,
|
|
Category: flagCategory,
|
|
Department: flagDepartment,
|
|
Query: flagQuery,
|
|
Sort: flagSort,
|
|
Limit: flagLimit,
|
|
})
|
|
if len(items) == 0 {
|
|
continue
|
|
}
|
|
|
|
bogoDeals := 0
|
|
score := 0.0
|
|
for _, item := range items {
|
|
if filter.ContainsIgnoreCase(item.Categories, "bogo") {
|
|
bogoDeals++
|
|
}
|
|
score += filter.DealScore(item)
|
|
}
|
|
|
|
results = append(results, compareStoreResult{
|
|
Number: storeNumber,
|
|
Name: store.Name,
|
|
City: store.City,
|
|
State: store.State,
|
|
Distance: strings.TrimSpace(store.Distance),
|
|
MatchedDeals: len(items),
|
|
BogoDeals: bogoDeals,
|
|
Score: score,
|
|
TopDeal: topDealTitle(items[0]),
|
|
})
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
if errCount == len(stores) {
|
|
return upstreamError("fetching deals", fmt.Errorf("all %d store lookups failed", len(stores)))
|
|
}
|
|
return notFoundError(
|
|
"no stores have deals matching your filters",
|
|
"Relax filters like --category/--department/--query.",
|
|
)
|
|
}
|
|
|
|
sort.SliceStable(results, func(i, j int) bool {
|
|
if results[i].MatchedDeals != results[j].MatchedDeals {
|
|
return results[i].MatchedDeals > results[j].MatchedDeals
|
|
}
|
|
if results[i].Score != results[j].Score {
|
|
return results[i].Score > results[j].Score
|
|
}
|
|
return parseDistance(results[i].Distance) < parseDistance(results[j].Distance)
|
|
})
|
|
for i := range results {
|
|
results[i].Rank = i + 1
|
|
}
|
|
|
|
if flagJSON {
|
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(results)
|
|
}
|
|
|
|
fmt.Fprintf(cmd.OutOrStdout(), "\nStore comparison near %s (%d matching store(s))\n\n", flagZip, len(results))
|
|
for _, r := range results {
|
|
fmt.Fprintf(
|
|
cmd.OutOrStdout(),
|
|
"%d. #%s %s (%s, %s)\n matches: %d | bogo: %d | score: %.1f | distance: %s mi\n top: %s\n\n",
|
|
r.Rank,
|
|
r.Number,
|
|
r.Name,
|
|
r.City,
|
|
r.State,
|
|
r.MatchedDeals,
|
|
r.BogoDeals,
|
|
r.Score,
|
|
emptyIf(r.Distance, "?"),
|
|
r.TopDeal,
|
|
)
|
|
}
|
|
if errCount > 0 {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "note: skipped %d store(s) due to upstream fetch errors.\n", errCount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func topDealTitle(item api.SavingItem) string {
|
|
if title := filter.CleanText(filter.Deref(item.Title)); title != "" {
|
|
return title
|
|
}
|
|
if desc := filter.CleanText(filter.Deref(item.Description)); desc != "" {
|
|
return desc
|
|
}
|
|
if item.ID != "" {
|
|
return "Deal " + item.ID
|
|
}
|
|
return "Untitled deal"
|
|
}
|
|
|
|
func parseDistance(raw string) float64 {
|
|
for _, token := range strings.Fields(raw) {
|
|
clean := strings.Trim(token, ",")
|
|
if d, err := strconv.ParseFloat(clean, 64); err == nil {
|
|
return d
|
|
}
|
|
}
|
|
return 999999
|
|
}
|
|
|
|
func emptyIf(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|