Enhance filter pipeline with synonym-aware categories and deal sorting

- extend filter.Options with sort mode support and keep Apply as a single-pass pipeline with limit behavior preserved for unsorted flows
- add sort normalization and two ordering strategies:
  * savings: rank by computed DealScore with deterministic title tie-break
  * ending: rank by earliest parsed end date, then DealScore fallback
- introduce DealScore heuristics that combine BOGO weighting, dollar-off extraction, and percentage extraction from savings/deal-info text
- add category synonym matcher that supports:
  * direct case-insensitive matches
  * canonical group synonym expansion (e.g. veggies -> produce)
  * normalized fallback for hyphen/underscore/plural variants without breaking exact unknown-category matching
- include explicit tests for synonym matching, hyphenated category handling, unknown plural exact matching, and sort ordering behavior
- keep allocation-sensitive behavior intact while adding matcher precomputation and fast-path checks
This commit is contained in:
2026-02-23 00:26:55 -05:00
parent b91c44c4ed
commit eb2328b768
4 changed files with 300 additions and 4 deletions

View File

@@ -2,6 +2,7 @@ package filter
import (
"html"
"sort"
"strings"
"github.com/tayloree/publix-deals/internal/api"
@@ -13,6 +14,7 @@ type Options struct {
Category string
Department string
Query string
Sort string
Limit int
}
@@ -22,8 +24,10 @@ func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
wantDepartment := opts.Department != ""
wantQuery := opts.Query != ""
needsFiltering := opts.BOGO || wantCategory || wantDepartment || wantQuery
sortMode := normalizeSortMode(opts.Sort)
hasSort := sortMode != ""
if !needsFiltering {
if !needsFiltering && !hasSort {
if opts.Limit > 0 && opts.Limit < len(items) {
return items[:opts.Limit]
}
@@ -37,9 +41,10 @@ func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
result = make([]api.SavingItem, 0, len(items))
}
category := opts.Category
department := strings.ToLower(opts.Department)
query := strings.ToLower(opts.Query)
applyLimitWhileFiltering := !hasSort && opts.Limit > 0
categoryMatcher := newCategoryMatcher(opts.Category)
for _, item := range items {
if opts.BOGO || wantCategory {
@@ -50,7 +55,7 @@ func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
if !hasBogo && strings.EqualFold(c, "bogo") {
hasBogo = true
}
if !hasCategory && strings.EqualFold(c, category) {
if !hasCategory && categoryMatcher.matches(c) {
hasCategory = true
}
if hasBogo && hasCategory {
@@ -76,11 +81,18 @@ func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
}
result = append(result, item)
if opts.Limit > 0 && len(result) >= opts.Limit {
if applyLimitWhileFiltering && len(result) >= opts.Limit {
break
}
}
if hasSort && len(result) > 1 {
sortItems(result, sortMode)
}
if opts.Limit > 0 && opts.Limit < len(result) {
result = result[:opts.Limit]
}
if len(result) == 0 {
return nil
}
@@ -132,3 +144,35 @@ func ContainsIgnoreCase(slice []string, val string) bool {
}
return false
}
func sortItems(items []api.SavingItem, mode string) {
switch mode {
case "savings":
sort.SliceStable(items, func(i, j int) bool {
left := DealScore(items[i])
right := DealScore(items[j])
if left == right {
return strings.ToLower(CleanText(Deref(items[i].Title))) < strings.ToLower(CleanText(Deref(items[j].Title)))
}
return left > right
})
case "ending":
sort.SliceStable(items, func(i, j int) bool {
leftDate, leftOK := parseDealDate(items[i].EndFormatted)
rightDate, rightOK := parseDealDate(items[j].EndFormatted)
switch {
case leftOK && rightOK:
if leftDate.Equal(rightDate) {
return DealScore(items[i]) > DealScore(items[j])
}
return leftDate.Before(rightDate)
case leftOK:
return true
case rightOK:
return false
default:
return DealScore(items[i]) > DealScore(items[j])
}
})
}
}