ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

markdown table of stores (#355)

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
15e81d3f 0970ca74

+421 -17
+226 -14
cmd/zipstorecount/main.go
··· 9 9 "errors" 10 10 "flag" 11 11 "fmt" 12 + "io" 12 13 "log" 13 14 "os" 14 15 "sort" 16 + "strconv" 15 17 "strings" 16 18 "sync" 19 + "text/tabwriter" 17 20 "time" 18 21 ) 19 22 20 23 type zipStoreCount struct { 21 24 Metro string 22 25 Zip string 26 + Chain string 23 27 Count int 24 28 } 25 29 ··· 30 34 31 35 func main() { 32 36 var inputPath string 37 + var outputFormat string 33 38 var timeoutSeconds int 34 39 35 40 flag.StringVar(&inputPath, "input", "zipcodes.txt", "Path to CSV/TXT file containing zip codes") 41 + flag.StringVar(&outputFormat, "format", "csv", "Output format: csv, table, or markdown") 36 42 flag.IntVar(&timeoutSeconds, "timeout", 20, "HTTP timeout in seconds for each zip query") 37 43 flag.Parse() 38 44 ··· 61 67 log.Fatalf("failed to create location storage: %v", err) 62 68 } 63 69 wg := sync.WaitGroup{} 64 - resultsChan := make(chan zipStoreCount, len(metroZipCodes)) 70 + resultsChan := make(chan zipQueryResult, len(metroZipCodes)) 65 71 for _, code := range metroZipCodes { 66 72 wg.Add(1) 67 73 go func(mzc metroZipCode) { 68 74 defer wg.Done() 69 - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 75 + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds)*time.Second) 70 76 stores, err := client.GetLocationsByZip(ctx, mzc.Zip) 71 77 cancel() 72 78 if err != nil { 73 - log.Fatalf("failed to query locations for zip %s: %v", mzc.Zip, err) 79 + resultsChan <- zipQueryResult{ 80 + err: fmt.Errorf("query locations for zip %s: %w", mzc.Zip, err), 81 + } 82 + return 74 83 } 75 - resultsChan <- zipStoreCount{Metro: mzc.Metro, Zip: mzc.Zip, Count: len(stores)} 84 + resultsChan <- zipQueryResult{ 85 + counts: countStoresByChain(mzc, stores), 86 + } 87 + 76 88 }(code) 77 89 } 78 90 wg.Wait() 79 91 close(resultsChan) 80 92 81 - results := make([]zipStoreCount, 0, len(metroZipCodes)) 93 + counts := make([]zipStoreCount, 0, len(metroZipCodes)) 82 94 for result := range resultsChan { 83 - results = append(results, result) 95 + if result.err != nil { 96 + log.Fatal(result.err) 97 + } 98 + counts = append(counts, result.counts...) 84 99 } 85 - 86 - sort.Slice(results, func(i, j int) bool { 87 - if results[i].Count == results[j].Count { 88 - return results[i].Zip < results[j].Zip 100 + sort.Slice(counts, func(i, j int) bool { 101 + if counts[i].Metro != counts[j].Metro { 102 + return counts[i].Metro < counts[j].Metro 103 + } 104 + if counts[i].Zip != counts[j].Zip { 105 + return counts[i].Zip < counts[j].Zip 106 + } 107 + if counts[i].Count != counts[j].Count { 108 + return counts[i].Count > counts[j].Count 89 109 } 90 - return results[i].Count < results[j].Count 110 + return counts[i].Chain < counts[j].Chain 91 111 }) 92 112 93 - fmt.Println("metro_name,zip_code,store_count") 94 - for _, result := range results { 95 - fmt.Printf("%s,%s,%d\n", result.Metro, result.Zip, result.Count) 113 + if err := writeCounts(os.Stdout, counts, metroZipCodes, outputFormat); err != nil { 114 + log.Fatal(err) 96 115 } 116 + } 117 + 118 + type zipQueryResult struct { 119 + counts []zipStoreCount 120 + err error 121 + } 122 + 123 + func countStoresByChain(mzc metroZipCode, stores []locations.Location) []zipStoreCount { 124 + counts := make(map[string]int, len(stores)) 125 + for _, store := range stores { 126 + counts[locationChain(store)]++ 127 + } 128 + 129 + results := make([]zipStoreCount, 0, len(counts)) 130 + for chain, count := range counts { 131 + results = append(results, zipStoreCount{ 132 + Metro: mzc.Metro, 133 + Zip: mzc.Zip, 134 + Chain: chain, 135 + Count: count, 136 + }) 137 + } 138 + 139 + return results 140 + } 141 + 142 + func locationChain(store locations.Location) string { 143 + if chain := normalizeChainName(store.Chain); chain != "" { 144 + return chain 145 + } 146 + 147 + if prefix, _, ok := strings.Cut(strings.TrimSpace(store.ID), "_"); ok { 148 + if chain := normalizeChainName(prefix); chain != "" { 149 + return chain 150 + } 151 + } 152 + 153 + if isAllDigits(strings.TrimSpace(store.ID)) { 154 + return "kroger" 155 + } 156 + 157 + return "unknown" 158 + } 159 + 160 + func normalizeChainName(raw string) string { 161 + chain := strings.ToLower(strings.TrimSpace(raw)) 162 + if chain == "" { 163 + return "" 164 + } 165 + return chain 166 + } 167 + 168 + func writeCounts(w io.Writer, counts []zipStoreCount, metroZipCodes []metroZipCode, format string) error { 169 + switch strings.ToLower(strings.TrimSpace(format)) { 170 + case "csv": 171 + return writeCSV(w, counts) 172 + case "table": 173 + return writeTable(w, counts, metroZipCodes) 174 + case "markdown", "md": 175 + return writeMarkdownTable(w, counts, metroZipCodes) 176 + default: 177 + return fmt.Errorf("unsupported format %q", format) 178 + } 179 + } 180 + 181 + func writeCSV(w io.Writer, counts []zipStoreCount) error { 182 + writer := csv.NewWriter(w) 183 + if err := writer.Write([]string{"metro_name", "zip_code", "chain", "store_count"}); err != nil { 184 + return fmt.Errorf("write csv header: %w", err) 185 + } 186 + for _, result := range counts { 187 + if err := writer.Write([]string{ 188 + result.Metro, 189 + result.Zip, 190 + result.Chain, 191 + strconv.Itoa(result.Count), 192 + }); err != nil { 193 + return fmt.Errorf("write csv row: %w", err) 194 + } 195 + } 196 + writer.Flush() 197 + if err := writer.Error(); err != nil { 198 + return fmt.Errorf("flush csv: %w", err) 199 + } 200 + return nil 201 + } 202 + 203 + func writeTable(w io.Writer, counts []zipStoreCount, metroZipCodes []metroZipCode) error { 204 + chains, rows, countsByRow := pivotCounts(counts, metroZipCodes) 205 + 206 + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) 207 + header := []string{"metro_name", "zip_code"} 208 + header = append(header, chains...) 209 + header = append(header, "total") 210 + if _, err := fmt.Fprintln(tw, strings.Join(header, "\t")); err != nil { 211 + return fmt.Errorf("write table header: %w", err) 212 + } 213 + 214 + for _, row := range rows { 215 + key := rowKey(row) 216 + line := []string{row.Metro, row.Zip} 217 + total := 0 218 + for _, chain := range chains { 219 + count := countsByRow[key][chain] 220 + line = append(line, strconv.Itoa(count)) 221 + total += count 222 + } 223 + line = append(line, strconv.Itoa(total)) 224 + if _, err := fmt.Fprintln(tw, strings.Join(line, "\t")); err != nil { 225 + return fmt.Errorf("write table row: %w", err) 226 + } 227 + } 228 + 229 + if err := tw.Flush(); err != nil { 230 + return fmt.Errorf("flush table: %w", err) 231 + } 232 + return nil 233 + } 234 + 235 + func writeMarkdownTable(w io.Writer, counts []zipStoreCount, metroZipCodes []metroZipCode) error { 236 + chains, rows, countsByRow := pivotCounts(counts, metroZipCodes) 237 + 238 + header := []string{"metro_name", "zip_code"} 239 + header = append(header, chains...) 240 + header = append(header, "total") 241 + if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(header, " | ")); err != nil { 242 + return fmt.Errorf("write markdown header: %w", err) 243 + } 244 + 245 + separator := make([]string, len(header)) 246 + for i := range separator { 247 + separator[i] = "---" 248 + } 249 + if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(separator, " | ")); err != nil { 250 + return fmt.Errorf("write markdown separator: %w", err) 251 + } 252 + 253 + for _, row := range rows { 254 + key := rowKey(row) 255 + line := []string{escapeMarkdownCell(row.Metro), escapeMarkdownCell(row.Zip)} 256 + total := 0 257 + for _, chain := range chains { 258 + count := countsByRow[key][chain] 259 + line = append(line, strconv.Itoa(count)) 260 + total += count 261 + } 262 + line = append(line, strconv.Itoa(total)) 263 + if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(line, " | ")); err != nil { 264 + return fmt.Errorf("write markdown row: %w", err) 265 + } 266 + } 267 + 268 + return nil 269 + } 270 + 271 + type rowKey struct { 272 + Metro string 273 + Zip string 274 + } 275 + 276 + func pivotCounts(counts []zipStoreCount, metroZipCodes []metroZipCode) ([]string, []metroZipCode, map[rowKey]map[string]int) { 277 + chainSet := make(map[string]struct{}, len(counts)) 278 + countsByRow := make(map[rowKey]map[string]int, len(metroZipCodes)) 279 + for _, count := range counts { 280 + key := rowKey{Metro: count.Metro, Zip: count.Zip} 281 + if countsByRow[key] == nil { 282 + countsByRow[key] = make(map[string]int) 283 + } 284 + countsByRow[key][count.Chain] = count.Count 285 + if count.Chain != "" { 286 + chainSet[count.Chain] = struct{}{} 287 + } 288 + } 289 + 290 + chains := make([]string, 0, len(chainSet)) 291 + for chain := range chainSet { 292 + chains = append(chains, chain) 293 + } 294 + sort.Strings(chains) 295 + 296 + rows := append([]metroZipCode(nil), metroZipCodes...) 297 + sort.Slice(rows, func(i, j int) bool { 298 + if rows[i].Metro != rows[j].Metro { 299 + return rows[i].Metro < rows[j].Metro 300 + } 301 + return rows[i].Zip < rows[j].Zip 302 + }) 303 + 304 + return chains, rows, countsByRow 305 + } 306 + 307 + func escapeMarkdownCell(value string) string { 308 + return strings.ReplaceAll(value, "|", "\\|") 97 309 } 98 310 99 311 func readZipCodes(path string) ([]metroZipCode, error) {
+158
cmd/zipstorecount/main_test.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bytes" 5 + "careme/internal/locations" 4 6 "reflect" 7 + "sort" 8 + "strings" 5 9 "testing" 6 10 ) 7 11 ··· 72 76 }) 73 77 } 74 78 } 79 + 80 + func TestCountStoresByChain(t *testing.T) { 81 + stores := []locations.Location{ 82 + {ID: "70500874", Chain: "Kroger"}, 83 + {ID: "70500875", Chain: " Kroger "}, 84 + {ID: "walmart_3098"}, 85 + {ID: "wholefoods_10216", Chain: "wholefoods"}, 86 + {ID: "safeway_1444"}, 87 + {ID: "mystery"}, 88 + } 89 + 90 + got := countStoresByChain(metroZipCode{Metro: "Seattle", Zip: "98032"}, stores) 91 + want := []zipStoreCount{ 92 + {Metro: "Seattle", Zip: "98032", Chain: "kroger", Count: 2}, 93 + {Metro: "Seattle", Zip: "98032", Chain: "walmart", Count: 1}, 94 + {Metro: "Seattle", Zip: "98032", Chain: "wholefoods", Count: 1}, 95 + {Metro: "Seattle", Zip: "98032", Chain: "safeway", Count: 1}, 96 + {Metro: "Seattle", Zip: "98032", Chain: "unknown", Count: 1}, 97 + } 98 + 99 + sortZipStoreCounts(got) 100 + sortZipStoreCounts(want) 101 + if !reflect.DeepEqual(got, want) { 102 + t.Fatalf("unexpected chain counts: got=%v want=%v", got, want) 103 + } 104 + } 105 + 106 + func TestLocationChain(t *testing.T) { 107 + tests := []struct { 108 + name string 109 + store locations.Location 110 + want string 111 + }{ 112 + { 113 + name: "uses explicit chain", 114 + store: locations.Location{ID: "70500874", Chain: "Kroger"}, 115 + want: "kroger", 116 + }, 117 + { 118 + name: "falls back to id prefix", 119 + store: locations.Location{ID: "safeway_1444"}, 120 + want: "safeway", 121 + }, 122 + { 123 + name: "falls back to kroger for numeric ids", 124 + store: locations.Location{ID: "70500874"}, 125 + want: "kroger", 126 + }, 127 + { 128 + name: "returns unknown when no signal exists", 129 + store: locations.Location{ID: "mystery"}, 130 + want: "unknown", 131 + }, 132 + } 133 + 134 + for _, tt := range tests { 135 + t.Run(tt.name, func(t *testing.T) { 136 + if got := locationChain(tt.store); got != tt.want { 137 + t.Fatalf("locationChain(%+v) = %q, want %q", tt.store, got, tt.want) 138 + } 139 + }) 140 + } 141 + } 142 + 143 + func TestWriteCSV_EscapesMetroName(t *testing.T) { 144 + counts := []zipStoreCount{ 145 + {Metro: "Seattle-Tacoma-Bellevue WA, Metro", Zip: "98032", Chain: "kroger", Count: 2}, 146 + } 147 + 148 + var buf bytes.Buffer 149 + if err := writeCSV(&buf, counts); err != nil { 150 + t.Fatalf("writeCSV returned error: %v", err) 151 + } 152 + 153 + want := "metro_name,zip_code,chain,store_count\n\"Seattle-Tacoma-Bellevue WA, Metro\",98032,kroger,2\n" 154 + if buf.String() != want { 155 + t.Fatalf("unexpected csv output:\n%s\nwant:\n%s", buf.String(), want) 156 + } 157 + } 158 + 159 + func TestWriteTable_PivotsChainsAndTotals(t *testing.T) { 160 + metroZipCodes := []metroZipCode{ 161 + {Metro: "Seattle", Zip: "98032"}, 162 + {Metro: "Boston", Zip: "02169"}, 163 + } 164 + counts := []zipStoreCount{ 165 + {Metro: "Seattle", Zip: "98032", Chain: "kroger", Count: 2}, 166 + {Metro: "Seattle", Zip: "98032", Chain: "walmart", Count: 1}, 167 + {Metro: "Boston", Zip: "02169", Chain: "wholefoods", Count: 3}, 168 + } 169 + 170 + var buf bytes.Buffer 171 + if err := writeTable(&buf, counts, metroZipCodes); err != nil { 172 + t.Fatalf("writeTable returned error: %v", err) 173 + } 174 + 175 + got := strings.Split(strings.TrimSpace(buf.String()), "\n") 176 + want := []string{ 177 + "metro_name zip_code kroger walmart wholefoods total", 178 + "Boston 02169 0 0 3 3", 179 + "Seattle 98032 2 1 0 3", 180 + } 181 + if !reflect.DeepEqual(got, want) { 182 + t.Fatalf("unexpected table output: got=%q want=%q", got, want) 183 + } 184 + } 185 + 186 + func TestWriteMarkdownTable_PivotsChainsAndEscapesCells(t *testing.T) { 187 + metroZipCodes := []metroZipCode{ 188 + {Metro: "Boston | Cambridge", Zip: "02169"}, 189 + {Metro: "Seattle", Zip: "98032"}, 190 + } 191 + counts := []zipStoreCount{ 192 + {Metro: "Seattle", Zip: "98032", Chain: "kroger", Count: 2}, 193 + {Metro: "Seattle", Zip: "98032", Chain: "walmart", Count: 1}, 194 + {Metro: "Boston | Cambridge", Zip: "02169", Chain: "wholefoods", Count: 3}, 195 + } 196 + 197 + var buf bytes.Buffer 198 + if err := writeMarkdownTable(&buf, counts, metroZipCodes); err != nil { 199 + t.Fatalf("writeMarkdownTable returned error: %v", err) 200 + } 201 + 202 + want := strings.Join([]string{ 203 + "| metro_name | zip_code | kroger | walmart | wholefoods | total |", 204 + "| --- | --- | --- | --- | --- | --- |", 205 + "| Boston \\| Cambridge | 02169 | 0 | 0 | 3 | 3 |", 206 + "| Seattle | 98032 | 2 | 1 | 0 | 3 |", 207 + "", 208 + }, "\n") 209 + if buf.String() != want { 210 + t.Fatalf("unexpected markdown output:\n%s\nwant:\n%s", buf.String(), want) 211 + } 212 + } 213 + 214 + func TestWriteCounts_RejectsUnknownFormat(t *testing.T) { 215 + var buf bytes.Buffer 216 + err := writeCounts(&buf, nil, nil, "json") 217 + if err == nil { 218 + t.Fatal("expected error") 219 + } 220 + } 221 + 222 + func sortZipStoreCounts(counts []zipStoreCount) { 223 + sort.Slice(counts, func(i, j int) bool { 224 + if counts[i].Metro != counts[j].Metro { 225 + return counts[i].Metro < counts[j].Metro 226 + } 227 + if counts[i].Zip != counts[j].Zip { 228 + return counts[i].Zip < counts[j].Zip 229 + } 230 + return counts[i].Chain < counts[j].Chain 231 + }) 232 + }
+1
internal/albertsons/cache.go
··· 105 105 ZipCode: summary.ZipCode, 106 106 Lat: summary.Lat, 107 107 Lon: summary.Lon, 108 + Chain: Container, 108 109 } 109 110 }
+4 -1
internal/albertsons/locations_test.go
··· 34 34 if err != nil { 35 35 t.Fatalf("GetLocationByID returned error: %v", err) 36 36 } 37 - if loc.Name != "Safeway 15100 SE 38th St" || loc.ZipCode != "98006" { 37 + if loc.Name != "Safeway 15100 SE 38th St" || loc.ZipCode != "98006" || loc.Chain != "albertsons" { 38 38 t.Fatalf("unexpected location: %+v", loc) 39 39 } 40 40 } ··· 66 66 } 67 67 if locs[0].ID != "safeway_1444" { 68 68 t.Fatalf("unexpected location id: %q", locs[0].ID) 69 + } 70 + if locs[0].Chain != "albertsons" { 71 + t.Fatalf("unexpected location chain: %q", locs[0].Chain) 69 72 } 70 73 } 71 74
+4
internal/kroger/locations.go
··· 6 6 "fmt" 7 7 ) 8 8 9 + const chainName = "kroger" 10 + 9 11 func (c *ClientWithResponses) IsID(locationID string) bool { 10 12 if locationID == "" { 11 13 return false ··· 51 53 ZipCode: zipCode, 52 54 Lat: lat, 53 55 Lon: lon, 56 + Chain: chainName, 54 57 }, nil 55 58 } 56 59 ··· 91 94 ZipCode: zipCode, 92 95 Lat: lat, 93 96 Lon: lon, 97 + Chain: chainName, 94 98 }) 95 99 } 96 100 return locations, nil
+18 -1
internal/kroger/locations_test.go
··· 1 1 package kroger 2 2 3 - import "testing" 3 + import ( 4 + locationtypes "careme/internal/locations/types" 5 + "testing" 6 + ) 4 7 5 8 func TestClientWithResponsesIsID(t *testing.T) { 6 9 t.Parallel() ··· 37 40 t.Fatalf("unexpected conversion: %v", got) 38 41 } 39 42 } 43 + 44 + func TestChainNameIsCanonicalized(t *testing.T) { 45 + t.Parallel() 46 + 47 + loc := locationtypes.Location{ 48 + ID: "70500874", 49 + Name: "QFC Bellevue", 50 + Chain: chainName, 51 + Address: "10116 NE 8th St", 52 + } 53 + if loc.Chain != "kroger" { 54 + t.Fatalf("unexpected chain: %q", loc.Chain) 55 + } 56 + }
+1
internal/locations/types/location.go
··· 11 11 Lat *float64 `json:"lat,omitempty"` 12 12 Lon *float64 `json:"lon,omitempty"` 13 13 CachedAt time.Time `json:"cached_at,omitempty"` 14 + Chain string `json:"chain,omitempty"` 14 15 } 15 16 16 17 type ZipCentroid struct {
+1
internal/walmart/locations.go
··· 36 36 ZipCode: store.Zip, 37 37 Lat: &lat, 38 38 Lon: &lon, 39 + Chain: "walmart", 39 40 } 40 41 }
+3
internal/walmart/store_test.go
··· 81 81 if location.Lon == nil || *location.Lon != -122.139487 { 82 82 t.Fatalf("unexpected location longitude: %v", location.Lon) 83 83 } 84 + if location.Chain != "walmart" { 85 + t.Fatalf("unexpected location chain: %q", location.Chain) 86 + } 84 87 }
+1
internal/wholefoods/cache.go
··· 128 128 ZipCode: summary.PrimaryLocation.Address.ZipCode, 129 129 Lat: &lat, 130 130 Lon: &lon, 131 + Chain: "wholefoods", 131 132 } 132 133 }
+4 -1
internal/wholefoods/locations_test.go
··· 31 31 if err != nil { 32 32 t.Fatalf("GetLocationByID returned error: %v", err) 33 33 } 34 - if loc.Name != "Whole Foods Westlake" || loc.ZipCode != "98121" { 34 + if loc.Name != "Whole Foods Westlake" || loc.ZipCode != "98121" || loc.Chain != "wholefoods" { 35 35 t.Fatalf("unexpected location: %+v", loc) 36 36 } 37 37 } ··· 63 63 } 64 64 if locs[0].ID != "wholefoods_10216" { 65 65 t.Fatalf("unexpected location id: %q", locs[0].ID) 66 + } 67 + if locs[0].Chain != "wholefoods" { 68 + t.Fatalf("unexpected location chain: %q", locs[0].Chain) 66 69 } 67 70 } 68 71