ai cooking
0
fork

Configure Feed

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

Add mobile "Use your location" ZIP finder on homepage (#292)

* Add mobile location ZIP lookup on homepage

* Always show location button on homepage

* get centroids out of location storage load it instead

* centroid refactor

* htmx and some inline javascript

* e2e tests

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
80379976 1234ff7c

+257 -49
+4 -2
cmd/careme/web.go
··· 49 49 return fmt.Errorf("failed to create recipe generator: %w", err) 50 50 } 51 51 52 - locationStorage, err := locations.New(cfg, cache) 52 + centroids := locations.LoadCentroids() 53 + 54 + locationStorage, err := locations.New(cfg, cache, centroids) 53 55 if err != nil { 54 56 return fmt.Errorf("failed to create location server: %w", err) 55 57 } ··· 57 59 userHandler := users.NewHandler(userStorage, locationStorage, authClient) 58 60 userHandler.Register(mux) 59 61 60 - locationServer := locations.NewServer(locationStorage, userStorage) 62 + locationServer := locations.NewServer(locationStorage, centroids, userStorage) 61 63 locationServer.Register(mux, authClient) 62 64 63 65 sitemapHandler := sitemap.New(cache)
+49 -2
cmd/careme/web_e2e_test.go
··· 113 113 114 114 } 115 115 116 + func TestZipFromCoordinatesHTMXRedirect(t *testing.T) { 117 + srv := newTestServer(t) 118 + defer srv.Close() 119 + 120 + client := newTestClient(t) 121 + req, err := http.NewRequest(http.MethodGet, srv.URL+"/locations/zip-from-coordinates?lat=47.6097&lon=-122.3331", nil) 122 + if err != nil { 123 + t.Fatalf("failed to build request: %v", err) 124 + } 125 + req.Header.Set("HX-Request", "true") 126 + 127 + resp, err := client.Do(req) 128 + if err != nil { 129 + t.Fatalf("request failed: %v", err) 130 + } 131 + defer func() { 132 + if err := resp.Body.Close(); err != nil { 133 + t.Fatalf("failed to close response body: %v", err) 134 + } 135 + }() 136 + 137 + if resp.StatusCode != http.StatusNoContent { 138 + t.Fatalf("expected status %d, got %d", http.StatusNoContent, resp.StatusCode) 139 + } 140 + if got := resp.Header.Get("HX-Redirect"); got != "/locations?zip=98101" { 141 + t.Fatalf("expected HX-Redirect %q, got %q", "/locations?zip=98101", got) 142 + } 143 + } 144 + 145 + func TestZipFromCoordinatesRejectsNonHTMX(t *testing.T) { 146 + srv := newTestServer(t) 147 + defer srv.Close() 148 + 149 + client := newTestClient(t) 150 + resp := mustGet(t, client, srv.URL+"/locations/zip-from-coordinates?lat=47.6097&lon=-122.3331") 151 + defer func() { 152 + if err := resp.Body.Close(); err != nil { 153 + t.Fatalf("failed to close response body: %v", err) 154 + } 155 + }() 156 + 157 + if resp.StatusCode != http.StatusBadRequest { 158 + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode) 159 + } 160 + } 161 + 116 162 func newTestServer(t *testing.T) *httptest.Server { 117 163 t.Helper() 118 164 ··· 130 176 if err != nil { 131 177 t.Fatalf("failed to create generator: %v", err) 132 178 } 133 - locationStorage, err := locations.New(cfg, cacheStore) 179 + centroids := locations.LoadCentroids() 180 + locationStorage, err := locations.New(cfg, cacheStore, centroids) 134 181 if err != nil { 135 182 t.Fatalf("failed to create location server: %v", err) 136 183 } ··· 138 185 mockAuth := auth.Mock(cfg) 139 186 140 187 mux := http.NewServeMux() 141 - locationServer := locations.NewServer(locationStorage, userStorage) 188 + locationServer := locations.NewServer(locationStorage, centroids, userStorage) 142 189 locationServer.Register(mux, mockAuth) 143 190 users.NewHandler(userStorage, locationStorage, mockAuth).Register(mux) 144 191 recipes.NewHandler(cfg, userStorage, generator, locationStorage, cacheStore, mockAuth).Register(mux)
+3 -1
cmd/zipstorecount/main.go
··· 54 54 log.Fatalf("failed to create cache: %v", err) 55 55 } 56 56 57 - client, err := locations.New(cfg, cacheStore) 57 + centroids := locations.LoadCentroids() 58 + 59 + client, err := locations.New(cfg, cacheStore, centroids) 58 60 if err != nil { 59 61 log.Fatalf("failed to create location storage: %v", err) 60 62 }
+55 -15
internal/locations/locations.go
··· 19 19 "log/slog" 20 20 "math" 21 21 "net/http" 22 + "net/url" 22 23 "sort" 24 + "strconv" 23 25 "sync" 24 26 "time" 25 27 ) ··· 30 32 31 33 type locationStorage struct { 32 34 client []locationBackend 33 - zipCentroids map[string]ZipCentroid 35 + zipCentroids centroidByZip 34 36 cache cache.Cache 35 37 } 36 38 ··· 40 42 41 43 type locationServer struct { 42 44 storage locationGetter 45 + zipFetcher zipFetcher 43 46 userStorage userLookup 44 47 } 45 48 46 49 type locationGetter interface { 47 50 GetLocationByID(ctx context.Context, locationID string) (*Location, error) 48 51 GetLocationsByZip(ctx context.Context, zipcode string) ([]Location, error) 52 + } 53 + 54 + type zipFetcher interface { 55 + NearestZIPToCoordinates(lat, lon float64) (string, bool) 49 56 } 50 57 51 58 type locationBackend interface { ··· 56 63 // Location is kept as an alias for compatibility with existing imports. 57 64 type Location = locationtypes.Location 58 65 59 - func New(cfg *config.Config, c cache.Cache) (locationGetter, error) { 66 + type centroidByZip interface { 67 + ZipCentroidByZIP(zip string) (ZipCentroid, bool) 68 + } 69 + 70 + func New(cfg *config.Config, c cache.Cache, centroids centroidByZip) (locationGetter, error) { 60 71 if c == nil { 61 72 return nil, fmt.Errorf("cache is required") 62 73 } 63 74 if cfg.Mocks.Enable { 75 + //should probably have something else return th mock so we can just return concerete type here. 64 76 return mock{}, nil 65 77 } 66 78 ··· 79 91 } 80 92 backends = append(backends, wclient) 81 93 } 82 - zipCentroids, err := loadEmbeddedZipCentroids() 83 - if err != nil { 84 - return nil, fmt.Errorf("failed to load zip centroids: %w", err) 85 - } 86 94 return &locationStorage{ 87 95 client: backends, 88 - zipCentroids: zipCentroids, 96 + zipCentroids: centroids, 89 97 cache: c, 90 98 }, nil 91 99 92 100 } 93 101 94 - func NewServer(storage locationGetter, userStorage userLookup) *locationServer { 102 + func NewServer(storage locationGetter, zipFetcher zipFetcher, userStorage userLookup) *locationServer { 95 103 return &locationServer{ 96 104 storage: storage, 105 + zipFetcher: zipFetcher, 97 106 userStorage: userStorage, 98 107 } 99 108 } ··· 124 133 } 125 134 126 135 func (l *locationStorage) GetLocationsByZip(ctx context.Context, zipcode string) ([]Location, error) { 127 - requestedCentroid, hasRequestedCentroid := zipCentroidByZIP(zipcode, l.zipCentroids) 136 + requestedCentroid, hasRequestedCentroid := l.zipCentroids.ZipCentroidByZIP(zipcode) 128 137 if !hasRequestedCentroid { 129 138 slog.ErrorContext(ctx, "requested zip has no centroid; skipping distance filter and sort", "zip", zipcode) 130 139 return nil, fmt.Errorf("invalid zip code %s. Can't find lat long", zipcode) ··· 159 168 160 169 filtered := make([]Location, 0, len(allLocations)) 161 170 for _, loc := range allLocations { 162 - if _, hasZipCentroid := zipCentroidByZIP(loc.ZipCode, l.zipCentroids); !hasZipCentroid { 171 + if _, hasZipCentroid := l.zipCentroids.ZipCentroidByZIP(loc.ZipCode); !hasZipCentroid { 163 172 slog.WarnContext(ctx, "location has no zip centroid; skipping distance filter and sort", "location_id", loc.ID, "zip", loc.ZipCode) 164 173 continue 165 174 } ··· 231 240 return nil 232 241 } 233 242 234 - func sortLocationsByDistanceFromCentroid(locations []Location, requestedCentroid ZipCentroid, zipCentroids map[string]ZipCentroid) { 243 + func sortLocationsByDistanceFromCentroid(locations []Location, requestedCentroid ZipCentroid, zipCentroids centroidByZip) { 235 244 sort.SliceStable(locations, func(i, j int) bool { 236 245 leftDistance := locationDistanceTo(requestedCentroid, locations[i], zipCentroids) 237 246 rightDistance := locationDistanceTo(requestedCentroid, locations[j], zipCentroids) ··· 239 248 }) 240 249 } 241 250 242 - func locationDistanceTo(target ZipCentroid, loc Location, zipCentroids map[string]ZipCentroid) float64 { 251 + func locationDistanceTo(target ZipCentroid, loc Location, zipCentroids centroidByZip) float64 { 243 252 lat, lon := locationCoordinates(loc, zipCentroids) 244 253 return haversineMiles(target.Lat, target.Lon, lat, lon) 245 254 } 246 255 247 - func locationCoordinates(loc Location, zipCentroids map[string]ZipCentroid) (float64, float64) { 256 + func locationCoordinates(loc Location, zipCentroids centroidByZip) (float64, float64) { 248 257 if loc.Lat != nil && loc.Lon != nil { 249 258 return *loc.Lat, *loc.Lon 250 259 } 251 260 252 261 //do we actualyl want to fall back? 253 - centroid, _ := zipCentroidByZIP(loc.ZipCode, zipCentroids) 262 + centroid, _ := zipCentroids.ZipCentroidByZIP(loc.ZipCode) 254 263 return centroid.Lat, centroid.Lon 255 264 } 256 265 ··· 283 292 } 284 293 285 294 func (l *locationServer) Register(mux *http.ServeMux, authClient auth.AuthClient) { 286 - mux.HandleFunc("/locations", func(w http.ResponseWriter, r *http.Request) { 295 + mux.HandleFunc("GET /locations/zip-from-coordinates", func(w http.ResponseWriter, r *http.Request) { 296 + isHXRequest := r.Header.Get("HX-Request") == "true" 297 + if !isHXRequest { 298 + http.Error(w, "htmx request required", http.StatusBadRequest) 299 + return 300 + } 301 + 302 + lat, err := strconv.ParseFloat(r.URL.Query().Get("lat"), 64) 303 + if err != nil { 304 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 305 + _, _ = w.Write([]byte("Try again, chef. We could not read your location.")) 306 + return 307 + } 308 + lon, err := strconv.ParseFloat(r.URL.Query().Get("lon"), 64) 309 + if err != nil { 310 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 311 + _, _ = w.Write([]byte("Try again, chef. We could not read your location.")) 312 + return 313 + } 314 + 315 + zip, ok := l.zipFetcher.NearestZIPToCoordinates(lat, lon) 316 + if !ok { 317 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 318 + _, _ = w.Write([]byte("Try again, chef. We could not find your ZIP code.")) 319 + return 320 + } 321 + 322 + w.Header().Set("HX-Redirect", "/locations?zip="+url.QueryEscape(zip)) 323 + w.WriteHeader(http.StatusNoContent) 324 + }) 325 + 326 + mux.HandleFunc("GET /locations", func(w http.ResponseWriter, r *http.Request) { 287 327 ctx := r.Context() 288 328 currentUser, err := l.userStorage.FromRequest(ctx, r, authClient) 289 329 if err != nil {
+13 -4
internal/locations/locations_test.go
··· 237 237 } 238 238 } 239 239 240 + func TestLocationStorageNearestZIPToCoordinates(t *testing.T) { 241 + centroids := LoadCentroids() 242 + 243 + zip, ok := centroids.NearestZIPToCoordinates(47.6097, -122.3331) 244 + if !ok { 245 + t.Fatal("expected nearest zip for valid coordinates") 246 + } 247 + if zip != "98101" { 248 + t.Fatalf("unexpected nearest zip: got %q want %q", zip, "98101") 249 + } 250 + } 251 + 240 252 func TestGetLocationsByZipSucceedsWhenAtLeastOneBackendSucceeds(t *testing.T) { 241 253 fail := newFakeLocationClient() 242 254 fail.err = fmt.Errorf("backend down") ··· 313 325 } 314 326 315 327 func newTestLocationServerWithBackendsAndCache(backends []locationBackend, c cachepkg.Cache) *locationStorage { 316 - zipCentroids, err := loadEmbeddedZipCentroids() 317 - if err != nil { 318 - panic(err) 319 - } 328 + zipCentroids := LoadCentroids() 320 329 return &locationStorage{ 321 330 client: backends, 322 331 zipCentroids: zipCentroids,
+7
internal/locations/mock.go
··· 43 43 return lo.Values(fakes), nil 44 44 } 45 45 46 + func (m mock) NearestZIPToCoordinates(lat, lon float64) (string, bool) { 47 + for _, location := range fakes { 48 + return location.ZipCode, true 49 + } 50 + return "", false 51 + } 52 + 46 53 func (m mock) Register(mux *http.ServeMux, _ auth.AuthClient) { 47 54 mux.HandleFunc("/locations", func(w http.ResponseWriter, r *http.Request) { 48 55 data := struct {
+38 -8
internal/locations/zip_centroids.go
··· 14 14 Lon float64 15 15 } 16 16 17 + type zipCentroidIndex struct { 18 + centroids map[string]ZipCentroid 19 + } 20 + 21 + func (z zipCentroidIndex) Len() int { 22 + return len(z.centroids) 23 + } 24 + 17 25 var ( 18 26 // zip_centroids.csv generation notes: 19 27 // 1) Downloaded U.S. Census Bureau Gazetteer ZCTA centroids: ··· 28 36 zipCentroidsCSV []byte 29 37 ) 30 38 31 - func loadEmbeddedZipCentroids() (map[string]ZipCentroid, error) { 32 - return parseZipCentroidsCSV(zipCentroidsCSV) 39 + func LoadCentroids() zipCentroidIndex { 40 + zci, err := parseZipCentroidsCSV(zipCentroidsCSV) 41 + if err != nil { 42 + panic("failed to parse embedded zip centroids dataset: " + err.Error()) 43 + } 44 + return zci 33 45 } 34 46 35 - func parseZipCentroidsCSV(raw []byte) (map[string]ZipCentroid, error) { 47 + func parseZipCentroidsCSV(raw []byte) (zipCentroidIndex, error) { 36 48 reader := csv.NewReader(bytes.NewReader(raw)) 37 49 rows, err := reader.ReadAll() 38 50 if err != nil { 39 - return nil, err 51 + return zipCentroidIndex{}, err 40 52 } 41 53 if len(rows) == 0 { 42 - return nil, errors.New("empty centroid dataset") 54 + return zipCentroidIndex{}, errors.New("empty centroid dataset") 43 55 } 44 56 45 57 data := make(map[string]ZipCentroid, len(rows)-1) ··· 58 70 } 59 71 data[row[0]] = ZipCentroid{Lat: lat, Lon: lon} 60 72 } 61 - return data, nil 73 + return zipCentroidIndex{centroids: data}, nil 62 74 } 63 75 64 - func zipCentroidByZIP(zip string, centroids map[string]ZipCentroid) (ZipCentroid, bool) { 76 + func (z zipCentroidIndex) ZipCentroidByZIP(zip string) (ZipCentroid, bool) { 65 77 zip5, ok := normalizeZIP(zip) 66 78 if !ok { 67 79 return ZipCentroid{}, false 68 80 } 69 81 70 - centroid, ok := centroids[zip5] 82 + centroid, ok := z.centroids[zip5] 71 83 return centroid, ok 84 + } 85 + 86 + func (z zipCentroidIndex) NearestZIPToCoordinates(lat, lon float64) (string, bool) { 87 + if len(z.centroids) == 0 { 88 + return "", false 89 + } 90 + 91 + nearestZip := "" 92 + nearestDistance := 0.0 93 + for zip, centroid := range z.centroids { 94 + distance := haversineMiles(lat, lon, centroid.Lat, centroid.Lon) 95 + if nearestZip == "" || distance < nearestDistance { 96 + nearestZip = zip 97 + nearestDistance = distance 98 + } 99 + } 100 + 101 + return nearestZip, nearestZip != "" 72 102 } 73 103 74 104 func normalizeZIP(raw string) (string, bool) {
+35 -16
internal/locations/zip_centroids_test.go
··· 1 1 package locations 2 2 3 - import "testing" 3 + import ( 4 + "testing" 5 + ) 4 6 5 7 func TestZipCentroidByZIP_KnownZip(t *testing.T) { 6 8 t.Parallel() 7 9 8 - centroids := mustLoadEmbeddedZipCentroids(t) 9 - centroid, ok := zipCentroidByZIP("00601", centroids) 10 + centroids := LoadCentroids() 11 + centroid, ok := centroids.ZipCentroidByZIP("00601") 10 12 if !ok { 11 13 t.Fatal("expected centroid for 00601") 12 14 } ··· 21 23 func TestZipCentroidByZIP_ZipPlus4(t *testing.T) { 22 24 t.Parallel() 23 25 24 - centroids := mustLoadEmbeddedZipCentroids(t) 25 - centroid, ok := zipCentroidByZIP("00601-1234", centroids) 26 + centroids := LoadCentroids() 27 + centroid, ok := centroids.ZipCentroidByZIP("00601-1234") 26 28 if !ok { 27 29 t.Fatal("expected centroid for ZIP+4") 28 30 } ··· 34 36 func TestZipCentroidByZIP_Unknown(t *testing.T) { 35 37 t.Parallel() 36 38 37 - centroids := mustLoadEmbeddedZipCentroids(t) 38 - _, ok := zipCentroidByZIP("00000", centroids) 39 + centroids := LoadCentroids() 40 + _, ok := centroids.ZipCentroidByZIP("00000") 39 41 if ok { 40 42 t.Fatal("expected no centroid for unknown zip") 41 43 } ··· 44 46 func TestZipCentroidDataLoaded(t *testing.T) { 45 47 t.Parallel() 46 48 47 - centroids := mustLoadEmbeddedZipCentroids(t) 48 - if len(centroids) < 30000 { 49 - t.Fatalf("expected large centroid dataset, got %d", len(centroids)) 49 + centroids := LoadCentroids() 50 + if centroids.Len() < 30000 { 51 + t.Fatalf("expected large centroid dataset, got %d", centroids.Len()) 52 + } 53 + } 54 + 55 + func TestNearestZIPToCoordinates(t *testing.T) { 56 + t.Parallel() 57 + 58 + centroids := zipCentroidIndex{map[string]ZipCentroid{ 59 + "10001": {Lat: 40.7506, Lon: -73.9972}, 60 + "94105": {Lat: 37.7898, Lon: -122.3942}, 61 + "98101": {Lat: 47.6105, Lon: -122.3348}, 62 + }} 63 + 64 + zip, ok := centroids.NearestZIPToCoordinates(47.6097, -122.3331) 65 + if !ok { 66 + t.Fatal("expected nearest ZIP for valid coordinates") 67 + } 68 + if zip != "98101" { 69 + t.Fatalf("unexpected nearest ZIP: got %q want %q", zip, "98101") 50 70 } 51 71 } 52 72 53 - func mustLoadEmbeddedZipCentroids(t *testing.T) map[string]ZipCentroid { 54 - t.Helper() 73 + func TestNearestZIPToCoordinates_EmptyCentroids(t *testing.T) { 74 + t.Parallel() 55 75 56 - centroids, err := loadEmbeddedZipCentroids() 57 - if err != nil { 58 - t.Fatalf("loadEmbeddedZipCentroids error: %v", err) 76 + zip, ok := zipCentroidIndex{}.NearestZIPToCoordinates(47.6097, -122.3331) 77 + if ok { 78 + t.Fatalf("expected no nearest ZIP, got %q", zip) 59 79 } 60 - return centroids 61 80 }
+3 -1
internal/mail/mail.go
··· 63 63 return nil, fmt.Errorf("failed to create recipe generator: %w", err) 64 64 } 65 65 66 - locationserver, err := locations.New(cfg, cache) 66 + centroids := locations.LoadCentroids() 67 + 68 + locationserver, err := locations.New(cfg, cache, centroids) 67 69 if err != nil { 68 70 return nil, fmt.Errorf("failed to create location server: %w", err) 69 71 }
+50
internal/templates/home.html
··· 74 74 class="inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2.5 font-medium text-white shadow-md transition hover:bg-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 75 75 Find stores 76 76 </button> 77 + <button type="button" id="use-location-btn" 78 + hx-get="/locations/zip-from-coordinates" 79 + hx-trigger="zip:lookup" 80 + hx-include="#use-location-lat,#use-location-lon" 81 + hx-target="#use-location-message" 82 + hx-swap="innerHTML" 83 + class="inline-flex w-full items-center justify-center rounded-lg border border-brand-300 bg-white px-3 py-2 text-sm font-medium text-brand-700 shadow-sm transition hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 84 + Use your location 85 + </button> 86 + <input id="use-location-lat" name="lat" type="hidden" /> 87 + <input id="use-location-lon" name="lon" type="hidden" /> 88 + <p id="use-location-message" class="hidden text-xs text-ink-600" aria-live="polite"></p> 77 89 </div> 78 90 </form> 79 91 </div> ··· 137 149 </section> 138 150 </main> 139 151 {{template "clerk_refresh.html" .}} 152 + <script src="/static/htmx@2.0.8.js"></script> 153 + <script> 154 + (() => { 155 + const button = document.getElementById("use-location-btn"); 156 + const message = document.getElementById("use-location-message"); 157 + const latInput = document.getElementById("use-location-lat"); 158 + const lonInput = document.getElementById("use-location-lon"); 159 + 160 + if (!button || !message || !latInput || !lonInput || !window.htmx) { 161 + return; 162 + } 163 + 164 + const showMessage = (text) => { 165 + message.classList.remove("hidden"); 166 + message.textContent = text; 167 + }; 168 + 169 + button.addEventListener("click", () => { 170 + if (!navigator.geolocation) { 171 + showMessage("Sorry, chef. Location is not available in this browser."); 172 + return; 173 + } 174 + 175 + showMessage("Finding your ZIP code..."); 176 + navigator.geolocation.getCurrentPosition( 177 + (position) => { 178 + latInput.value = position.coords.latitude.toString(); 179 + lonInput.value = position.coords.longitude.toString(); 180 + window.htmx.trigger(button, "zip:lookup"); 181 + }, 182 + () => { 183 + showMessage("Sorry, chef. Have to allow location access."); 184 + }, 185 + { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 }, 186 + ); 187 + }); 188 + })(); 189 + </script> 140 190 </body> 141 191 </html>