ai cooking
0
fork

Configure Feed

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

Zipcodetest (#271)

* zip code counts

* current zip code distribtion

authored by

Paul Miller and committed by
GitHub
8ff13499 336057e4

+295
+196
cmd/zipstorecount/main.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/config" 5 + "careme/internal/kroger" 6 + "context" 7 + "encoding/csv" 8 + "errors" 9 + "flag" 10 + "fmt" 11 + "log" 12 + "net/http" 13 + "os" 14 + "sort" 15 + "strings" 16 + "time" 17 + ) 18 + 19 + type locationClient interface { 20 + LocationListWithResponse(ctx context.Context, params *kroger.LocationListParams, reqEditors ...kroger.RequestEditorFn) (*kroger.LocationListResponse, error) 21 + } 22 + 23 + type zipStoreCount struct { 24 + Metro string 25 + Zip string 26 + Count int 27 + } 28 + 29 + type metroZipCode struct { 30 + Metro string 31 + Zip string 32 + } 33 + 34 + func main() { 35 + var inputPath string 36 + var timeoutSeconds int 37 + 38 + flag.StringVar(&inputPath, "input", "zipcodes.txt", "Path to CSV/TXT file containing zip codes") 39 + flag.IntVar(&timeoutSeconds, "timeout", 20, "HTTP timeout in seconds for each zip query") 40 + flag.Parse() 41 + 42 + metroZipCodes, err := readZipCodes(inputPath) 43 + if err != nil { 44 + log.Fatalf("failed to read zip codes: %v", err) 45 + } 46 + if len(metroZipCodes) == 0 { 47 + log.Fatalf("no valid zip codes found in %s", inputPath) 48 + } 49 + 50 + client, err := newLocationClientFromEnv() 51 + if err != nil { 52 + log.Fatalf("failed to initialize Kroger client: %v", err) 53 + } 54 + 55 + results := make([]zipStoreCount, 0, len(metroZipCodes)) 56 + for _, metroZipCode := range metroZipCodes { 57 + ctx, cancel := context.WithTimeout(context.Background(), durationFromSeconds(timeoutSeconds)) 58 + count, err := countLocationsByZip(ctx, client, metroZipCode.Zip) 59 + cancel() 60 + if err != nil { 61 + log.Fatalf("failed to query locations for zip %s: %v", metroZipCode.Zip, err) 62 + } 63 + results = append(results, zipStoreCount{Metro: metroZipCode.Metro, Zip: metroZipCode.Zip, Count: count}) 64 + } 65 + 66 + sort.Slice(results, func(i, j int) bool { 67 + if results[i].Count == results[j].Count { 68 + return results[i].Zip < results[j].Zip 69 + } 70 + return results[i].Count < results[j].Count 71 + }) 72 + 73 + fmt.Println("metro_name,zip_code,store_count") 74 + for _, result := range results { 75 + fmt.Printf("%s,%s,%d\n", result.Metro, result.Zip, result.Count) 76 + } 77 + } 78 + 79 + func readZipCodes(path string) ([]metroZipCode, error) { 80 + file, err := os.Open(path) 81 + if err != nil { 82 + return nil, fmt.Errorf("open input file: %w", err) 83 + } 84 + defer func() { 85 + _ = file.Close() 86 + }() 87 + 88 + reader := csv.NewReader(file) 89 + reader.FieldsPerRecord = -1 90 + 91 + records, err := reader.ReadAll() 92 + if err != nil { 93 + return nil, fmt.Errorf("read csv: %w", err) 94 + } 95 + 96 + return extractZipCodes(records) 97 + } 98 + 99 + // assume metro is in the first column and zip code is in the second column. 100 + // Ignore header row if present. Normalize zip codes to 5 digits and ignore invalid entries. 101 + // Remove duplicate zip codes and keep the first metro seen for each zip. 102 + func extractZipCodes(records [][]string) ([]metroZipCode, error) { 103 + if len(records) == 0 { 104 + return nil, errors.New("empty input file") 105 + } 106 + 107 + zipCodes := make([]metroZipCode, 0, len(records)) 108 + seen := make(map[string]struct{}, len(records)) 109 + 110 + for _, row := range records { 111 + if len(row) < 2 { 112 + continue 113 + } 114 + 115 + metroName := strings.TrimSpace(row[0]) 116 + zipCode, ok := normalizeZipCode(row[1]) 117 + if !ok { 118 + continue 119 + } 120 + 121 + if _, exists := seen[zipCode]; exists { 122 + continue 123 + } 124 + seen[zipCode] = struct{}{} 125 + zipCodes = append(zipCodes, metroZipCode{Metro: metroName, Zip: zipCode}) 126 + } 127 + 128 + return zipCodes, nil 129 + } 130 + 131 + func normalizeZipCode(raw string) (string, bool) { 132 + zipCode := strings.TrimSpace(raw) 133 + 134 + if len(zipCode) == 5 && isAllDigits(zipCode) { 135 + return zipCode, true 136 + } 137 + if len(zipCode) == 10 && zipCode[5] == '-' && isAllDigits(zipCode[:5]) && isAllDigits(zipCode[6:]) { 138 + return zipCode[:5], true 139 + } 140 + 141 + return "", false 142 + } 143 + 144 + func isAllDigits(value string) bool { 145 + for i := range value { 146 + if value[i] < '0' || value[i] > '9' { 147 + return false 148 + } 149 + } 150 + return true 151 + } 152 + 153 + func newLocationClientFromEnv() (locationClient, error) { 154 + clientID := strings.TrimSpace(os.Getenv("KROGER_CLIENT_ID")) 155 + clientSecret := strings.TrimSpace(os.Getenv("KROGER_CLIENT_SECRET")) 156 + if clientID == "" || clientSecret == "" { 157 + return nil, errors.New("KROGER_CLIENT_ID and KROGER_CLIENT_SECRET must be set") 158 + } 159 + 160 + cfg := &config.Config{ 161 + Kroger: config.KrogerConfig{ 162 + ClientID: clientID, 163 + ClientSecret: clientSecret, 164 + }, 165 + } 166 + 167 + client, err := kroger.FromConfig(cfg) 168 + if err != nil { 169 + return nil, err 170 + } 171 + return client, nil 172 + } 173 + 174 + func countLocationsByZip(ctx context.Context, client locationClient, zipCode string) (int, error) { 175 + params := &kroger.LocationListParams{ 176 + FilterZipCodeNear: &zipCode, 177 + } 178 + resp, err := client.LocationListWithResponse(ctx, params) 179 + if err != nil { 180 + return 0, err 181 + } 182 + if resp.StatusCode() != http.StatusOK { 183 + return 0, fmt.Errorf("status %d: %s", resp.StatusCode(), resp.Status()) 184 + } 185 + if resp.JSON200 == nil || resp.JSON200.Data == nil { 186 + return 0, nil 187 + } 188 + return len(*resp.JSON200.Data), nil 189 + } 190 + 191 + func durationFromSeconds(seconds int) time.Duration { 192 + if seconds <= 0 { 193 + return 20 * time.Second 194 + } 195 + return time.Duration(seconds) * time.Second 196 + }
+74
cmd/zipstorecount/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "reflect" 5 + "testing" 6 + ) 7 + 8 + func TestExtractZipCodes_UsesSecondColumn(t *testing.T) { 9 + records := [][]string{ 10 + {"Seattle", "98032", "ignore"}, 11 + {"Boston", "02169-1234", "ignore"}, 12 + {"Duplicate Metro", "98032", "ignore"}, 13 + {"Bad", "not-a-zip", "ignore"}, 14 + } 15 + 16 + got, err := extractZipCodes(records) 17 + if err != nil { 18 + t.Fatalf("extractZipCodes returned error: %v", err) 19 + } 20 + 21 + want := []metroZipCode{ 22 + {Metro: "Seattle", Zip: "98032"}, 23 + {Metro: "Boston", Zip: "02169"}, 24 + } 25 + if !reflect.DeepEqual(got, want) { 26 + t.Fatalf("unexpected metro+zip codes: got=%v want=%v", got, want) 27 + } 28 + } 29 + 30 + func TestExtractZipCodes_SkipsRowsMissingSecondColumn(t *testing.T) { 31 + records := [][]string{ 32 + {"only-one-column"}, 33 + {"Seattle", "98032"}, 34 + {}, 35 + {"Boston", "02169"}, 36 + } 37 + 38 + got, err := extractZipCodes(records) 39 + if err != nil { 40 + t.Fatalf("extractZipCodes returned error: %v", err) 41 + } 42 + 43 + want := []metroZipCode{ 44 + {Metro: "Seattle", Zip: "98032"}, 45 + {Metro: "Boston", Zip: "02169"}, 46 + } 47 + if !reflect.DeepEqual(got, want) { 48 + t.Fatalf("unexpected metro+zip codes: got=%v want=%v", got, want) 49 + } 50 + } 51 + 52 + func TestNormalizeZipCode(t *testing.T) { 53 + tests := []struct { 54 + name string 55 + input string 56 + want string 57 + ok bool 58 + }{ 59 + {name: "five digits", input: "98032", want: "98032", ok: true}, 60 + {name: "zip plus four", input: "02169-1234", want: "02169", ok: true}, 61 + {name: "leading and trailing spaces", input: " 98032 ", want: "98032", ok: true}, 62 + {name: "non-digit content", input: "zip 98032", want: "", ok: false}, 63 + {name: "too short", input: "9803", want: "", ok: false}, 64 + } 65 + 66 + for _, tt := range tests { 67 + t.Run(tt.name, func(t *testing.T) { 68 + got, ok := normalizeZipCode(tt.input) 69 + if got != tt.want || ok != tt.ok { 70 + t.Fatalf("normalizeZipCode(%q) = (%q, %t), want (%q, %t)", tt.input, got, ok, tt.want, tt.ok) 71 + } 72 + }) 73 + } 74 + }
+25
cmd/zipstorecount/zipcodes.txt
··· 1 + New York-Newark-Jersey City NY-NJ-PA,11368 2 + Los Angeles-Long Beach-Anaheim CA,90011 3 + Chicago-Naperville-Elgin IL-IN-WI,60629 4 + Dallas-Fort Worth-Arlington TX,75040 5 + Houston-The Woodlands-Sugar Land TX,77084 6 + Philadelphia-Camden-Wilmington PA-NJ-DE-MD,19120 7 + Miami-Fort Lauderdale-Pompano Beach FL,33186 8 + Atlanta-Sandy Springs-Alpharetta GA,30044 9 + Washington-Arlington-Alexandria DC-VA-MD-WV,20906 10 + Boston-Cambridge-Newton MA-NH,02169 11 + San Francisco-Oakland-Berkeley CA,94565 12 + Riverside-San Bernardino-Ontario CA,92880 13 + Phoenix-Mesa-Chandler AZ,85383 14 + San Diego-Chula Vista-Carlsbad CA,92154 15 + Tampa-St. Petersburg-Clearwater FL,33647 16 + Seattle-Tacoma-Bellevue WA,98032 17 + Minneapolis-St. Paul-Bloomington MN-WI,55432 18 + Detroit-Warren-Dearborn MI,48235 19 + St. Louis MO-IL,63136 20 + Denver-Aurora-Lakewood CO,80249 21 + Baltimore-Columbia-Towson MD,21234 22 + Charlotte-Concord-Gastonia NC-SC,28277 23 + Orlando-Kissimmee-Sanford FL,34787 24 + San Antonio-New Braunfels TX,78249 25 + Portland-Vancouver-Hillsboro OR-WA,97086